diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_captcha_host_check.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_captcha_host_check.py | 188 |
1 files changed, 188 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_captcha_host_check.py b/packages/meshbay-hub/tests/test_captcha_host_check.py new file mode 100644 index 0000000..bbd6c6d --- /dev/null +++ b/packages/meshbay-hub/tests/test_captcha_host_check.py @@ -0,0 +1,188 @@ +"""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"}) |