From 51705056a578d2649a989bf14a056fbcfae58a6b Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 13:34:47 +0200 Subject: 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 --- packages/meshbay-hub/tests/conftest.py | 4 + packages/meshbay-hub/tests/test_android_shell.py | 3 - .../meshbay-hub/tests/test_captcha_host_check.py | 252 --------------------- packages/meshbay-hub/tests/test_desktop_shell.py | 34 +-- .../meshbay-hub/tests/test_register_captcha.py | 148 ++++++++++-- .../meshbay-hub/tests/test_security_headers.py | 12 +- .../tests/test_unauthenticated_surface.py | 3 +- 7 files changed, 149 insertions(+), 307 deletions(-) delete mode 100644 packages/meshbay-hub/tests/test_captcha_host_check.py (limited to 'packages/meshbay-hub/tests') diff --git a/packages/meshbay-hub/tests/conftest.py b/packages/meshbay-hub/tests/conftest.py index bb0ed28..5104ae2 100644 --- a/packages/meshbay-hub/tests/conftest.py +++ b/packages/meshbay-hub/tests/conftest.py @@ -29,6 +29,7 @@ def hub_key_path(tmp_path_factory) -> Path: @pytest.fixture def hub_config(hub_key_path, tmp_path): from meshbay_hub.config import ( + CaptchaConfig, DatabaseConfig, HubConfig, HubIdentityConfig, @@ -40,6 +41,9 @@ def hub_config(hub_key_path, tmp_path): server=ServerConfig(host="127.0.0.1", port=8000), identity=HubIdentityConfig(id="test-hub", private_key_path=hub_key_path), jwt=JWTConfig(access_token_ttl=3600), + # Off here, so every test that registers is not also solving a proof of + # work; test_register_captcha.py turns it on. + captcha=CaptchaConfig(enabled=False), ) return cfg diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index cd5a36e..1049a5f 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -71,7 +71,6 @@ def test_the_policy_is_the_desktop_policy_sent_as_a_header(): body = text.split(start, 1)[1].split(end, 1)[0] out = {} for d in re.findall(r"[`\"]([a-z-]+(?: [^`\"]*)?)[`\"]", body): - d = d.replace("${RECAPTCHA_SRC}", "$RECAPTCHA_SRC") out[d.split()[0]] = d return out @@ -82,8 +81,6 @@ def test_the_policy_is_the_desktop_policy_sent_as_a_header(): assets = _read(SRC / "shell" / "UiAssets.kt") assert '"Content-Security-Policy" to CSP' in assets - recaptcha = 'RECAPTCHA_SRC = "https://www.google.com https://www.gstatic.com"' - assert recaptcha in assets markup = re.sub(r"", "", _read(CLIENT / "scripts" / "index.html"), flags=re.S) assert "Content-Security-Policy" not in markup 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"}) diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index c2ea4ca..7a26dfb 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -197,14 +197,9 @@ def _policy() -> str: """ import re source = _main() - # The array mixes plain strings and one `${RECAPTCHA_SRC}` template literal; - # resolve the constant so every directive reads as plain text. - rec = re.search(r"const RECAPTCHA_SRC = '([^']*)'", source) match = re.search(r"const CSP = \[(.*?)\]\.join", source, re.S) assert match, "no CSP constant in the main process" body = match.group(1) - if rec: - body = body.replace("${RECAPTCHA_SRC}", rec.group(1)) # Skip the `//` comments inside the array. Reading one as a directive is # the mistake this file already records against the packaged unit test, # which matched the comment explaining why `User=` was absent: parse @@ -226,24 +221,22 @@ def _directive(name: str) -> str: def test_the_hub_is_reachable_but_never_executable(): """ - The page connects to its own files and to reCAPTCHA, nothing else: every + The page connects to its own files, nothing else: every hub call leaves from the main process (a page on `app://` is refused by CORS anyway), and the node arrives over WebRTC, which connect-src does not govern. A bare `https:` there let injected script post anything anywhere. script-src never names the hub: nothing it returns is ever executed. The - only script sources are 'self', the wasm eval token, and the two reCAPTCHA - hosts (see the next test) — never a bare `https:` scheme, which would let - the hub's own origin serve script. + only script sources are 'self' and the wasm eval token — never a bare + `https:` scheme, which would let the hub's own origin serve script. """ connect = _directive("connect-src").split()[1:] - assert set(connect) == {"'self'", "https://www.google.com", "https://www.gstatic.com"} + assert set(connect) == {"'self'"} script = _directive("script-src") assert script, "no script-src directive" sources = script.split()[1:] # drop the "script-src" keyword itself allowed = { "'self'", "'wasm-unsafe-eval'", - "https://www.google.com", "https://www.gstatic.com", } assert set(sources) <= allowed, \ f"unexpected script-src source: {set(sources) - allowed}" @@ -252,22 +245,17 @@ def test_the_hub_is_reachable_but_never_executable(): assert "default-src 'none'" in _policy() -def test_recaptcha_is_the_only_third_party_and_stays_scoped_to_it(): +def test_no_third_party_origin_in_the_policy(): """ - reCAPTCHA gates sign-up in the app the same way it does in the browser. - www.google.com and www.gstatic.com are allowed under script-src, frame-src - and img-src for that — and no other external origin appears anywhere in the - policy. Remove this expectation only alongside the reCAPTCHA widget. + Nothing outside the application's own files is reachable from the page. + The one exception there was, reCAPTCHA's two Google origins under + script-src, frame-src and img-src, went with the captcha: sign-up is gated + by a proof of work the hub issues itself (meshbay_hub/captcha.py). """ - hosts = {"https://www.google.com", "https://www.gstatic.com"} - for directive in ("script-src", "frame-src", "img-src"): - srcs = set(_directive(directive).split()[1:]) - assert hosts <= srcs, f"{directive} is missing a reCAPTCHA host" - for part in _policy().split(";"): for tok in part.strip().split()[1:]: - if tok.startswith(("http://", "https://")): - assert tok in hosts, f"unexpected external origin in CSP: {tok}" + assert not tok.startswith(("http://", "https://")), ( + f"external origin in CSP: {tok}") def test_the_pdf_preview_has_both_permissions_it_needs(): diff --git a/packages/meshbay-hub/tests/test_register_captcha.py b/packages/meshbay-hub/tests/test_register_captcha.py index 5f94b9c..b63b197 100644 --- a/packages/meshbay-hub/tests/test_register_captcha.py +++ b/packages/meshbay-hub/tests/test_register_captcha.py @@ -1,27 +1,49 @@ -"""Registration CAPTCHA is enforced for every fresh account when configured. +"""Sign-up and passphrase reset are gated by the hub's own proof of work. -The gate used to be skipped whenever the request carried an `auth_key` — which -every real client sends (the password split) — so it protected nobody and a bot -skipped it by including the field. It now runs on `captcha.enabled` alone; the -desktop client is Chromium and renders the same widget. +The gate replaced reCAPTCHA, which sent every visitor's address and browser +details to Google and needed a consent the sign-up form could not ask for +freely. What it must still be: unconditional when enabled — the old gate was +once skipped for any request carrying an `auth_key`, which every real client +sends — and an answer the hub issued, in date, and used once. + +The last test runs the page's own solver (`static/captcha.js`) against a +challenge from `captcha.issue`, so the two halves cannot drift apart unseen. """ +import hashlib +import json +import shutil +import subprocess +import time +from pathlib import Path + import pytest +from meshbay_hub import captcha +from meshbay_hub.config import load_config + +STATIC = Path(captcha.__file__).parent / "static" @pytest.fixture def captcha_on(client, monkeypatch): - """Turn on a fake captcha: any config with both keys is `enabled`, and - verification succeeds only for the token 'good-token'.""" + """The check on, with little enough work that the tests solve it at once.""" from meshbay_hub.api.users import _cfg - monkeypatch.setattr(_cfg.captcha, "site_key", "test-site") - monkeypatch.setattr(_cfg.captcha, "secret_key", "test-secret") + monkeypatch.setattr(_cfg.captcha, "enabled", True) + monkeypatch.setattr(_cfg.captcha, "max_number", 2000) + monkeypatch.setattr(captcha, "_spent", {}) + - async def fake_verify(secret, token, remote_ip=None, allowed_hosts=None, - allow_unattributed=False): - return token == "good-token" +def _solve(ch: dict) -> str: + for n in range(ch["max_number"]): + if hashlib.sha256(f"{ch['prefix']}{n}".encode()).hexdigest() == ch["challenge"]: + return f"{ch['prefix']}{n}.{ch['signature']}" + raise AssertionError("no number matches the challenge") - monkeypatch.setattr("meshbay_hub.captcha.verify_captcha", fake_verify) + +async def _token(client) -> str: + r = await client.get("/v1/users/captcha") + assert r.status_code == 200, r.text + return _solve(r.json()) def _body(**over): @@ -31,29 +53,111 @@ def _body(**over): @pytest.mark.asyncio -async def test_missing_captcha_rejected_even_with_auth_key(client, captcha_on): +async def test_missing_answer_rejected_even_with_auth_key(client, captcha_on): r = await client.post("/v1/users/register", json=_body()) assert r.status_code == 400 assert r.json()["detail"] == "captcha_required" @pytest.mark.asyncio -async def test_bad_captcha_rejected(client, captcha_on): +async def test_solved_challenge_accepted(client, captcha_on): r = await client.post("/v1/users/register", - json=_body(captcha_token="wrong")) + json=_body(captcha_token=await _token(client))) + assert r.status_code == 201, r.text + + +@pytest.mark.asyncio +async def test_an_answer_is_good_for_one_form(client, captcha_on): + token = await _token(client) + r = await client.post("/v1/users/register", json=_body(captcha_token=token)) + assert r.status_code == 201, r.text + r = await client.post("/v1/users/register", json=_body( + username="another_test", email="another@t.com", captcha_token=token)) assert r.status_code == 400 assert r.json()["detail"] == "captcha_failed" @pytest.mark.asyncio -async def test_good_captcha_accepted(client, captcha_on): - r = await client.post("/v1/users/register", - json=_body(captcha_token="good-token")) - assert r.status_code == 201 +async def test_a_wrong_number_is_refused(client, captcha_on): + nonce, expires, number, sig = (await _token(client)).split(".") + forged = f"{nonce}.{expires}.{int(number) + 1}.{sig}" + r = await client.post("/v1/users/register", json=_body(captcha_token=forged)) + assert r.json()["detail"] == "captcha_failed" + + +@pytest.mark.asyncio +async def test_a_challenge_the_hub_did_not_sign_is_refused(client, captcha_on): + """Somebody minting their own easy challenge has no key to sign it with.""" + forged = f"{'00' * 12}.{int(time.time()) + 60}.0.{'0' * 64}" + r = await client.post("/v1/users/register", json=_body(captcha_token=forged)) + assert r.json()["detail"] == "captcha_failed" @pytest.mark.asyncio -async def test_no_captcha_configured_still_registers(client): - # Default test config has no captcha keys — registration proceeds without one. +async def test_an_expired_answer_is_refused(client, captcha_on, monkeypatch): + token = await _token(client) + later = time.time() + captcha.CHALLENGE_TTL + 1 + monkeypatch.setattr(captcha.time, "time", lambda: later) + r = await client.post("/v1/users/register", json=_body(captcha_token=token)) + assert r.json()["detail"] == "captcha_failed" + + +@pytest.mark.asyncio +async def test_password_reset_request_is_gated_too(client, captcha_on): + body = {"username": "nobody_test", "email": "nobody@t.com"} + r = await client.post("/v1/users/password/reset-request", json=body) + assert r.json()["detail"] == "captcha_required" + r = await client.post("/v1/users/password/reset-request", + json={**body, "captcha_token": await _token(client)}) + assert r.status_code == 200, r.text + + +@pytest.mark.asyncio +async def test_turned_off_it_issues_nothing_and_checks_nothing(client): + # The test configuration has it off (conftest.py). + r = await client.get("/v1/users/captcha") + assert r.status_code == 404 + assert r.json()["detail"] == "captcha_disabled" r = await client.post("/v1/users/register", json=_body()) assert r.status_code == 201 + + +def test_on_unless_the_configuration_says_otherwise(tmp_path): + cfg_file = tmp_path / "hub.toml" + cfg_file.write_text('[server]\nport = 8000\n', encoding="utf-8") + assert load_config(cfg_file).captcha.enabled + cfg_file.write_text('[captcha]\nenabled = false\n', encoding="utf-8") + assert not load_config(cfg_file).captcha.enabled + + +def test_old_recaptcha_keys_are_ignored_with_a_warning(tmp_path, monkeypatch): + # Caught at the logger rather than through caplog: other tests in the suite + # reconfigure logging, and caplog then sees nothing. + from meshbay_hub import config + warned = [] + monkeypatch.setattr(config.log, "warning", lambda msg, *a: warned.append(msg)) + cfg_file = tmp_path / "hub.toml" + cfg_file.write_text('[captcha]\nsite_key = "6Lsite"\nsecret_key = "6Lsecret"\n', + encoding="utf-8") + cfg = load_config(cfg_file) + assert cfg.captcha.enabled + assert any("no longer read" in m for m in warned) + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node is not available") +@pytest.mark.asyncio +async def test_the_page_solver_answers_the_hub(client, captcha_on, tmp_path): + ch = (await client.get("/v1/users/captcha")).json() + (tmp_path / "package.json").write_text('{"type":"module"}', encoding="utf-8") + (tmp_path / "captcha.js").write_text( + (STATIC / "captcha.js").read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "case.js").write_text( + "import { solve } from './captcha.js';\n" + f"console.log(JSON.stringify(await solve({json.dumps(ch)})));\n", encoding="utf-8") + proc = subprocess.run(["node", "case.js"], cwd=tmp_path, + capture_output=True, text=True, encoding="utf-8") + assert proc.returncode == 0, proc.stderr + token = json.loads(proc.stdout) + assert token == _solve(ch) + r = await client.post("/v1/users/register", json=_body(captcha_token=token)) + assert r.status_code == 201, r.text diff --git a/packages/meshbay-hub/tests/test_security_headers.py b/packages/meshbay-hub/tests/test_security_headers.py index b068656..e6d431e 100644 --- a/packages/meshbay-hub/tests/test_security_headers.py +++ b/packages/meshbay-hub/tests/test_security_headers.py @@ -64,12 +64,12 @@ def test_the_policy_is_locked_down_where_it_matters(): assert "https:" not in script.split() -def test_recaptcha_is_the_only_external_origin(): - hosts = {"https://www.google.com", "https://www.gstatic.com"} +def test_no_external_origin(): + """The sign-up check is the hub's own proof of work, not a third party's.""" for part in CSP.split(";"): for tok in part.strip().split()[1:]: - if tok.startswith(("http://", "https://")): - assert tok in hosts, f"unexpected external origin in CSP: {tok}" + assert not tok.startswith(("http://", "https://")), ( + f"external origin in CSP: {tok}") def test_the_streamed_download_frame_is_allowed(): @@ -78,8 +78,8 @@ def test_the_streamed_download_frame_is_allowed(): The streamed-download path works by navigating a hidden iframe to `/_mbdl/` so the service worker is asked for the response it is already - holding. `frame-src` was tightened to reCAPTCHA's two origins when the - captcha needed a frame, and nobody connected the two: Chrome refused the + holding. `frame-src` was tightened to a third-party captcha's two origins + when it needed a frame, and nobody connected the two: Chrome refused the frame, the worker was never asked, and the page waited out its timeout for a download that could not happen. On Firefox and Safari that is the *only* way to write a large file to disk — there is no File System Access API and diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py index aabcf45..f78f327 100644 --- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py +++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py @@ -27,10 +27,11 @@ PUBLIC = { ("GET", "/"): "the application shell", ("GET", "/app"): "the application shell", ("GET", "/app/{path:path}"): "the application shell", - ("GET", "/v1/hub/info"): "read before sign-in: captcha key, instance policy", + ("GET", "/v1/hub/info"): "read before sign-in: instance policy", ("GET", "/v1/hub/version"): "an installed client checks its minimum version first", ("GET", "/v1/hub/pubkey"): "nodes cache the hub key on first contact", ("GET", "/v1/health"): "supervision", + ("GET", "/v1/users/captcha"): "a proof-of-work challenge for sign-up and reset, per-IP limit", ("POST", "/v1/users/register"): "obtains an account — captcha, per-IP limit", ("POST", "/v1/users/login"): "obtains a session — per-IP limit, per-name lockout", ("POST", "/v1/users/auth"): "device sign-in — Ed25519 signature over a fresh timestamp", -- cgit v1.2.3