summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests')
-rw-r--r--packages/meshbay-hub/tests/conftest.py4
-rw-r--r--packages/meshbay-hub/tests/test_android_shell.py3
-rw-r--r--packages/meshbay-hub/tests/test_captcha_host_check.py252
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py34
-rw-r--r--packages/meshbay-hub/tests/test_register_captcha.py148
-rw-r--r--packages/meshbay-hub/tests/test_security_headers.py12
-rw-r--r--packages/meshbay-hub/tests/test_unauthenticated_surface.py3
7 files changed, 149 insertions, 307 deletions
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/<id>` 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",