aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_captcha_host_check.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 13:34:47 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 13:34:47 +0200
commit51705056a578d2649a989bf14a056fbcfae58a6b (patch)
treedfd078aba518427387fca29b9d43c3723b030008 /packages/meshbay-hub/tests/test_captcha_host_check.py
parentdaec4bc6a2a9f9e038e5269586b06018ff327a27 (diff)
downloadmeshbay-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.py252
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"})