diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-14 13:34:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-14 13:34:47 +0200 |
| commit | 51705056a578d2649a989bf14a056fbcfae58a6b (patch) | |
| tree | dfd078aba518427387fca29b9d43c3723b030008 /packages/meshbay-hub/tests/test_captcha_host_check.py | |
| parent | daec4bc6a2a9f9e038e5269586b06018ff327a27 (diff) | |
| download | meshbay-51705056a578d2649a989bf14a056fbcfae58a6b.tar.gz | |
feat(hub): replace reCAPTCHA with a self-hosted proof of work
Sign-up and passphrase reset sent every visitor's address and browser
details to Google, which needed a consent the sign-up form could not ask
for freely. The hub now issues its own challenge (ALTCHA's scheme, signed
with a key derived from the hub's private key, single-use, 30 minutes)
and the page solves it with SubtleCrypto while the form is filled in.
- GET /v1/users/captcha issues a challenge; 404 captcha_disabled when off.
- [captcha] enabled / max_number replace site_key / secret_key, which are
now ignored with a warning; MESHBAY_CAPTCHA_ENABLED replaces the old
variables.
- No Google origin left in the CSP of the hub, the desktop shell or the
Android shell; captcha_site_key leaves /v1/hub/info.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_captcha_host_check.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_captcha_host_check.py | 252 |
1 files changed, 0 insertions, 252 deletions
diff --git a/packages/meshbay-hub/tests/test_captcha_host_check.py b/packages/meshbay-hub/tests/test_captcha_host_check.py deleted file mode 100644 index a0ff509..0000000 --- a/packages/meshbay-hub/tests/test_captcha_host_check.py +++ /dev/null @@ -1,252 +0,0 @@ -"""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. - -The desktop client needed one more thing, found only by trying it. Its origin -is `app://meshbay`, and the guess was that Google would report the host part, -`meshbay`. It does not: a solve it cannot attribute to a domain comes back -with an **empty** hostname. `allow_unattributed_host` is what admits those, -and it is a named flag rather than an allowlist entry because a blank in a -TOML list is a typo far more often than an intention. -""" - -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_an_unattributed_solve_is_refused_by_default(google): - """What the desktop client actually produces, and what it costs. - - `app://meshbay` does not report `meshbay`. Google attributes the solve to - no domain at all and the hostname comes back **empty** — measured live, - after the first attempt to allowlist `meshbay` failed. Refusing by default - is right: an answer with no hostname must not read as "any hostname". - """ - google(success=True, hostname="") - assert not await verify_captcha( - "s", "t", allowed_hosts=frozenset({"meshbay.org", "localhost"})) - - -@pytest.mark.asyncio -async def test_the_flag_admits_it_and_the_allowlist_is_not_how(google): - """The flag is the only way in, on purpose. - - An empty allowlist entry would be the obvious alternative and is a bad - one: a blank in a TOML list is a typo far more often than an intention, - and `load_config` drops blanks for exactly that reason. So this must not - work by accident from an allowlist alone. - """ - google(success=True, hostname="") - allowed = frozenset({"meshbay.org", "localhost"}) - assert await verify_captcha("s", "t", allowed_hosts=allowed, allow_unattributed=True) - assert not await verify_captcha("s", "t", allowed_hosts=allowed | {""}) - - -@pytest.mark.asyncio -async def test_the_flag_does_not_open_the_allowlist(google): - """It admits *unattributed* solves, not solves from somewhere else. - - A bot on its own web page still reports that page's hostname, which is - still not ours, flag or no flag. - """ - google(success=True, hostname="a-bot-farm.example") - assert not await verify_captcha( - "s", "t", allowed_hosts=frozenset({"meshbay.org"}), allow_unattributed=True) - - -@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_an_absent_hostname_field_reads_like_an_empty_one(google): - """`hostname` missing and `hostname: ""` are the same answer.""" - google(success=True) - allowed = frozenset({"meshbay.org"}) - assert not await verify_captcha("s", "t", allowed_hosts=allowed) - assert await verify_captcha("s", "t", allowed_hosts=allowed, allow_unattributed=True) - - -@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"})) - - -@pytest.mark.asyncio -async def test_the_flag_alone_checks_nothing_without_an_allowlist(google): - """`allowed_hosts` empty still means "do not check", flag or no flag — - the whole block is skipped and reCAPTCHA is doing the origin check.""" - google(success=True, hostname="somewhere.example") - assert await verify_captcha("s", "t", allow_unattributed=False) - assert await verify_captcha("s", "t", allow_unattributed=True) - - -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', encoding="utf-8") - - 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', - encoding="utf-8") - - assert load_config(cfg_file).captcha.host_check is None - - -def test_the_unattributed_flag_comes_from_the_config(tmp_path, monkeypatch): - cfg_file = tmp_path / "hub.toml" - cfg_file.write_text( - '[captcha]\nsite_key = "k"\nsecret_key = "s"\n' - 'allowed_hosts = ["meshbay.org"]\nallow_unattributed_host = true\n', encoding="utf-8") - assert load_config(cfg_file).captcha.allow_unattributed_host is True - - cfg_file.write_text('[captcha]\nsite_key = "k"\nsecret_key = "s"\n', encoding="utf-8") - assert load_config(cfg_file).captcha.allow_unattributed_host is False, ( - "the default has to stay off — it is the looser of the two") - - monkeypatch.setenv("MESHBAY_CAPTCHA_ALLOW_UNATTRIBUTED_HOST", "true") - assert load_config(cfg_file).captcha.allow_unattributed_host is True - - -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"}) |