"""A solved captcha has to have been solved on one of our own pages. reCAPTCHA normally does this itself: the widget reports the hostname of the page it is rendered in, and Google compares it against the key's domain list. That is the check the desktop client cannot pass. Its interface ships inside the package and is served from `app://meshbay` — not a preference, Chromium refuses a service worker on a custom scheme and the hub must never become the document origin — so the hostname Google sees is not the hub's and never can be. The widget renders "ERROR for site owner: Invalid domain for site key", which no amount of client-side configuration reaches, because the decision is made on Google's servers. Turning the key's own origin check off moves the decision here. `siteverify` then reports the hostname it *observed* — not one the caller asserts — so this stays a real check: a bot that embeds the site key on a page of its own is refused on the hostname it actually served from, which is the attack that turning the check off would otherwise open. `allowed_hosts` empty means "do not check", which is what a deployment that left the origin check with Google wants. That is the default, so an existing hub upgrades without its captcha changing behaviour. """ import json import pytest from meshbay_hub.captcha import verify_captcha from meshbay_hub.config import CaptchaConfig, load_config class _Resp: def __init__(self, payload): self._payload = payload def raise_for_status(self): return None def json(self): return json.loads(json.dumps(self._payload)) class _Client: """Stands in for httpx.AsyncClient, and records what was sent.""" sent: list[dict] = [] def __init__(self, payload): self._payload = payload def __call__(self, *a, **kw): return self async def __aenter__(self): return self async def __aexit__(self, *exc): return False async def post(self, url, data=None): type(self).sent.append(dict(data or {})) return _Resp(self._payload) @pytest.fixture def google(monkeypatch): """`siteverify` answers whatever the test says it answers.""" def _answer(**payload): _Client.sent = [] monkeypatch.setattr("httpx.AsyncClient", _Client(payload)) return _answer @pytest.mark.asyncio async def test_a_solve_from_an_expected_host_is_accepted(google): google(success=True, hostname="meshbay.org") assert await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"})) @pytest.mark.asyncio async def test_the_desktop_client_s_own_host_is_just_another_entry(google): """`app://meshbay` is what the reported hostname comes from — the reason any of this exists. Nothing about it is special to the check.""" google(success=True, hostname="meshbay") allowed = frozenset({"meshbay.org", "localhost", "meshbay"}) assert await verify_captcha("s", "t", allowed_hosts=allowed) @pytest.mark.asyncio async def test_a_solve_from_somewhere_else_is_refused(google): """The attack that turning off the key's origin check would open: the site key is public, so a bot can render the widget on a page of its own.""" google(success=True, hostname="a-bot-farm.example") assert not await verify_captcha( "s", "t", allowed_hosts=frozenset({"meshbay.org", "meshbay"})) @pytest.mark.asyncio async def test_a_missing_hostname_is_refused_when_checking(google): """An answer with no hostname at all must not read as "any hostname".""" google(success=True) assert not await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"})) @pytest.mark.asyncio async def test_no_allowed_hosts_means_google_is_still_the_one_checking(google): """The default, and what every hub that never touched this config does. The hostname is whatever it is and this does not look: the key's own origin check is on, so the answer would not have come back successful otherwise. """ google(success=True, hostname="somewhere.example") assert await verify_captcha("s", "t") assert await verify_captcha("s", "t", allowed_hosts=None) @pytest.mark.asyncio async def test_a_failed_solve_is_refused_whatever_the_hostname(google): """Order matters: `success` first, and the host check never rescues a no.""" google(success=False, hostname="meshbay.org", **{"error-codes": ["timeout-or-duplicate"]}) assert not await verify_captcha( "s", "t", allowed_hosts=frozenset({"meshbay.org"})) @pytest.mark.asyncio async def test_the_remote_ip_still_rides_along(google): """Unchanged behaviour, pinned because the signature grew a parameter.""" google(success=True, hostname="meshbay.org") await verify_captcha("s", "t", "203.0.113.7", frozenset({"meshbay.org"})) assert _Client.sent == [{"secret": "s", "response": "t", "remoteip": "203.0.113.7"}] @pytest.mark.asyncio async def test_a_transport_failure_is_a_refusal_not_an_exception(monkeypatch): """Unchanged, and worth keeping: a captcha that cannot be checked is not a captcha that passed.""" class _Boom(_Client): async def post(self, url, data=None): raise RuntimeError("no route to host") monkeypatch.setattr("httpx.AsyncClient", _Boom({})) assert not await verify_captcha("s", "t", allowed_hosts=frozenset({"meshbay.org"})) def test_the_config_hands_over_a_set_or_nothing(): """`host_check` is what the caller passes straight through, so "unset" has to arrive as None rather than an empty set that refuses everything.""" assert CaptchaConfig().host_check is None assert CaptchaConfig(allowed_hosts=[]).host_check is None assert CaptchaConfig(allowed_hosts=["meshbay.org", "meshbay"]).host_check == frozenset( {"meshbay.org", "meshbay"}) def test_hub_toml_carries_the_allowed_hosts(tmp_path): """An operator who turns off the key's origin check sets this, and it is the only place they can: the value depends on the deployment.""" cfg_file = tmp_path / "hub.toml" cfg_file.write_text( '[captcha]\n' 'site_key = "6Lsite"\n' 'secret_key = "6Lsecret"\n' 'allowed_hosts = ["meshbay.org", " meshbay ", "", "localhost"]\n') cfg = load_config(cfg_file) assert cfg.captcha.enabled assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay", "localhost"}), ( "entries are stripped and blanks dropped — a stray space in a TOML " "list would otherwise refuse every solve from that host") def test_a_hub_toml_without_the_key_checks_nothing(tmp_path): """The upgrade path. A hub that never heard of this setting keeps the behaviour it has, with reCAPTCHA doing the origin check.""" cfg_file = tmp_path / "hub.toml" cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n') assert load_config(cfg_file).captcha.host_check is None def test_the_environment_can_override_the_list(monkeypatch, tmp_path): """Same shape as MESHBAY_ADMIN_USERS, for a container that has no file.""" monkeypatch.setenv("MESHBAY_CAPTCHA_ALLOWED_HOSTS", "meshbay.org, meshbay ,") cfg = load_config(tmp_path / "absent.toml") assert cfg.captcha.host_check == frozenset({"meshbay.org", "meshbay"})