summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-02 10:35:17 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-02 10:35:17 +0200
commit9c1b622611bb0b21852d3c9c11e1d8674aa35654 (patch)
tree68261f4451047a58c0554f0ceeddaf65214f7131
parente08bb408cf9963066f212028e69e22c46ff1cb54 (diff)
downloadmeshbay-9c1b622611bb0b21852d3c9c11e1d8674aa35654.tar.gz
fix(hub): check the captcha's origin here, so the desktop client can pass one
Reported from the native client: the reCAPTCHA box renders "ERROR for site owner: Invalid domain for site key". The web browser is fine. It is not a client restriction, and the CSP was never what refused — the script loads, which is why the widget appears at all to say so. reCAPTCHA validates the hostname of the page the widget is rendered in against the domain list on the site key, and the desktop client's interface ships inside the package and is served from `app://meshbay` (main.js: `win.loadURL`). Not a preference: file:// breaks ES modules and IndexedDB, and the hub must never become the document origin. So the hostname Google sees is `meshbay`, it is not on the key's list, and it never can be — the check runs on Google's servers and nothing client-side reaches it. The fix turns that check off on the key and does it on the hub instead: [captcha] allowed_hosts = ["meshbay.org", "localhost", "meshbay"] `verify_captcha` refuses a solve whose hostname is not in the list. The hostname comes from `siteverify` — what Google observed, not what the caller asserts — so it is a real check against what turning the console setting off opens, which is a bot rendering the public site key on a page of its own. Empty (the default) skips it, so an existing hub upgrades unchanged with reCAPTCHA still doing the origin check. The two settings go together, and docs/captcha.md §6 says so. The `meshbay` entry is the weak one and the doc says that too: any Electron application can claim the same scheme and host, as main.js already records. What it still costs is a captcha solve per token inside a real Chromium instead of a token farmed from any web page. docs/captcha.md §6 replaced. It documented a design that was superseded twice — an `auth_key`-keyed carve-out that turned out to disable the gate for everyone, and "works in the Electron client too, both run Chromium", which is the assumption this bug is made of: reCAPTCHA validates the domain, not the rendering engine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014UtzVrzM7e2tG9fSpkR9ML
-rw-r--r--docs/captcha.md110
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/captcha.py37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py18
-rw-r--r--packages/meshbay-hub/tests/test_captcha_host_check.py188
-rw-r--r--packages/meshbay-hub/tests/test_register_captcha.py2
6 files changed, 314 insertions, 42 deletions
diff --git a/docs/captcha.md b/docs/captcha.md
index c908a5c..84dadc4 100644
--- a/docs/captcha.md
+++ b/docs/captcha.md
@@ -12,7 +12,9 @@ reCAPTCHA v2 with the "I'm not a robot" checkbox. Reasons:
- Binary pass/fail — no score threshold to tune or monitor.
- The user is already filling a form; one checkbox is negligible friction.
-- Works in both the web SPA and the Electron client (both run Chromium).
+- Works in the web SPA. It works in the Electron client too, but not for the
+ reason "both run Chromium" — reCAPTCHA validates the *domain*, not the
+ rendering engine, and the desktop client's is not the hub's. See §6.
- v3 (invisible, score-based) is an option later if the checkbox proves annoying;
the server-side verification call is identical, only the client widget differs.
@@ -20,6 +22,9 @@ reCAPTCHA v2 with the "I'm not a robot" checkbox. Reasons:
`https://www.google.com/recaptcha/admin`. Register the hub's domain(s) —
`meshbay.org` and `localhost` for development. This produces a **site key**
(public, embedded in HTML) and a **secret key** (server-only, in `hub.toml`).
+If the desktop client is in use, also turn *off* "Verify the origin of
+reCAPTCHA solutions" on that key and set `allowed_hosts` — §6 says why, and
+what is given up.
---
@@ -376,49 +381,69 @@ captcha.failed: "Captcha verification failed — please try again"
---
-## 6. Desktop client (Electron)
+## 6. Desktop client (Electron), and the domain problem
-The Electron client loads its UI from the package (`app://meshbay`), not from
-the hub. Its CSP forbids external scripts by design — the renderer executes no
-code that did not ship in the package. Loading Google's reCAPTCHA script
-(`https://www.google.com/recaptcha/api.js`) would be a **new category of
-trust**: third-party executable code in a renderer that currently runs none.
+**This section replaced two earlier designs, and both are worth naming because
+the reasoning that produced them is the trap.**
-Two approaches:
+The first said: open the CSP to Google's reCAPTCHA domains, or skip the captcha
+for native clients — and recommended skipping it, keyed on `auth_key` being
+present. That carve-out shipped and was a hole: *every* real client sends
+`auth_key`, the browser included (it is the password split), so the gate was
+off for everybody and a bot skipped it by including the field. It is gone;
+`users.py` gates on `captcha.enabled` alone, and says so at the call site.
-- **Open the CSP to Google's reCAPTCHA domains.** Add
- `https://www.google.com/recaptcha/` and `https://www.gstatic.com/recaptcha/`
- to `script-src`, `frame-src` and `connect-src`. Functional, but undermines
- the principle that the renderer runs only packaged code — the reCAPTCHA
- script is fetched live and changes without the operator's knowledge.
-- **Skip the captcha for native clients** (recommended). The Electron client
- already raises the bar against automated account creation: it requires
- installation, generates a device Ed25519 key pair stored in `safeStorage`,
- and authenticates to the hub via `POST /v1/users/auth` with a signed
- challenge. A bot automating that path must install and drive a full Electron
- app, which is a harder problem than filling a web form — and the reCAPTCHA
- exists to solve the web-form problem.
+The second is the sentence in §1 above: "works in the Electron client too, both
+run Chromium". The CSP was opened (`RECAPTCHA_SRC` in `main.js`, covering
+`script-src`, `img-src` and `frame-src`) and the widget does render. It renders
+**"ERROR for site owner: Invalid domain for site key"**.
-**How to skip server-side.** The web SPA sends `password` (legacy) in the
-registration body; the Electron client sends `auth_key` (PBKDF2-derived via
-`window.MeshBayKeys.registerUser`). The server requires `captcha_token` only
-when `auth_key` is absent — i.e. the web path. This is not a security
-boundary: a bot that derives `auth_key` itself bypasses the check, but it also
-proves it can run the PBKDF2 derivation, which is the same cost as solving the
-captcha. The real gate for native-path abuse is the rate limiter (`5/minute`)
-and the email verification step.
+**Why.** reCAPTCHA validates the hostname of the page the widget is rendered
+in, against the domain list on the site key. The desktop client's interface
+ships inside the package and is served from `app://meshbay` (`main.js`:
+`win.loadURL('app://meshbay/index.html')`). Not a preference — `file://`
+breaks ES modules and IndexedDB, and the hub must never become the document
+origin, which is enforced by the `will-navigate` handler. So the hostname
+Google sees is `meshbay`, it is not on the key's list, and it never can be:
+the check happens on Google's servers and no client-side configuration reaches
+it. Widening the CSP does not help, because the CSP was never what refused.
-```python
-# In register(), captcha gate adjusted:
-if _cfg and _cfg.captcha.enabled and not body.auth_key:
- if not body.captcha_token:
- raise HTTPException(400, "captcha_required")
- ...
+**What is done instead.** Turn *off* "Verify the origin of reCAPTCHA
+solutions" on the key, and check the origin on the hub, where it belongs:
+
+```toml
+[captcha]
+site_key = "6Le..."
+secret_key = "6Le..."
+allowed_hosts = ["meshbay.org", "localhost", "meshbay"]
```
-On the Electron side: `registerUser()` in `keyderive.js` does not send
-`captcha_token`, and the server does not ask for one. No CSP change, no
-Google script loaded, no new trust boundary.
+`verify_captcha` then refuses a solve whose reported hostname is not in that
+list. The hostname comes from `siteverify` — it is what Google *observed*, not
+something the caller asserts — so this is a real check and not a formality: the
+site key is public, and the thing turning the origin check off opens is a bot
+rendering the widget on a page of its own, which this refuses on the hostname
+it actually served from.
+
+Empty (the default) means "do not check", so a hub that never touched this
+setting keeps the behaviour it has, with reCAPTCHA doing the origin check
+itself. **The two settings go together**: turning the console check off without
+setting `allowed_hosts` leaves no origin check anywhere.
+
+**The last entry is the desktop client's own, and it is the weak one.** Any
+Electron application can claim the same scheme and host — `main.js` already
+records that `app://meshbay` is not a credential, which is why the hub's API is
+reachable from no web origin at all and every call leaves from the main
+process. So `meshbay` in that list is spoofable by someone who builds an
+equivalent application. What it still costs them is a per-token captcha solve
+inside a real Chromium, rather than a token farmed from any web page. That is
+the trade, stated plainly; a hub that does not ship the desktop client should
+leave the entry out.
+
+**Confirming the hostname.** `meshbay` is the host component of
+`app://meshbay`. If a solve is refused, `captcha.py` logs it at WARNING with
+the hostname spelled out and the allowed list beside it, which is how to read
+the value a given client actually reports rather than guess at it.
---
@@ -475,11 +500,15 @@ from Google — no npm package.
## 10. Deployment steps
1. Obtain reCAPTCHA v2 keys from Google (register `meshbay.org` + `localhost`).
+ For a deployment with the desktop client, also turn off "Verify the origin
+ of reCAPTCHA solutions" on the key — §6.
2. Add to `/etc/meshbay/hub.toml` on the production server:
```toml
[captcha]
- site_key = "6Le..."
- secret_key = "6Le..."
+ site_key = "6Le..."
+ secret_key = "6Le..."
+ # Required whenever the console's origin check is off, and only then.
+ allowed_hosts = ["meshbay.org", "localhost", "meshbay"]
```
3. Deploy the new hub code (`deploy-hub.sh` — runs `alembic upgrade head` +
restart; no migration needed for this change).
@@ -488,3 +517,6 @@ from Google — no npm package.
solving the captcha.
5. Verify reset: open `https://meshbay.org/#/reset`, confirm the checkbox
appears. Request a reset code, confirm email arrives only after solving it.
+6. Verify the desktop client: register from it and confirm the widget solves
+ rather than showing "Invalid domain for site key". A refusal logged as
+ `captcha solved on an unexpected host` names the hostname to add.
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 9b70b59..ece98cd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -68,6 +68,7 @@ async def _verify_captcha_or_raise(token: str | None, request: Request) -> None:
_cfg.captcha.secret_key, # type: ignore[union-attr]
token,
request.client.host if request.client else None,
+ _cfg.captcha.host_check, # type: ignore[union-attr]
)
if not ok:
raise HTTPException(status_code=400, detail="captcha_failed")
diff --git a/packages/meshbay-hub/src/meshbay_hub/captcha.py b/packages/meshbay-hub/src/meshbay_hub/captcha.py
index faf7907..74a77b6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/captcha.py
+++ b/packages/meshbay-hub/src/meshbay_hub/captcha.py
@@ -10,8 +10,30 @@ VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
async def verify_captcha(
- secret_key: str, token: str, remote_ip: str | None = None,
+ secret_key: str,
+ token: str,
+ remote_ip: str | None = None,
+ allowed_hosts: frozenset[str] | set[str] | None = None,
) -> bool:
+ """True when Google accepts the token, and it came from a host we expect.
+
+ `allowed_hosts` is the answer to a reCAPTCHA key whose own origin check is
+ turned off. That check compares the *page's hostname* against the key's
+ domain list, and the desktop client's page is served from `app://meshbay`
+ — Chromium refuses a service worker on a custom scheme and the interface
+ ships inside the package, so it cannot be `https://<hub>` and the hostname
+ cannot be the hub's. Google answers "Invalid domain for site key" inside
+ its own widget, which is not something the client can configure away.
+
+ Turning the key's origin check off moves that decision here, where the hub
+ knows which hosts are its own. `siteverify` reports the hostname it
+ observed, not one the caller asserts, so this is a real check and not a
+ formality — a bot embedding the site key on a page of its own is refused
+ on the hostname it actually served from.
+
+ `None` (the default) skips it, which is what a deployment leaving the
+ origin check with Google wants: it is then already done, one layer up.
+ """
payload: dict[str, str] = {"secret": secret_key, "response": token}
if remote_ip:
payload["remoteip"] = remote_ip
@@ -22,7 +44,18 @@ async def verify_captcha(
result = resp.json()
if not result.get("success"):
log.info("captcha rejected: %s", result.get("error-codes", []))
- return result.get("success", False)
+ return False
+ if allowed_hosts is not None:
+ # Logged at warning with the hostname spelled out: this is also
+ # how an operator finds what to allow after adding a client
+ # whose origin they have not seen before.
+ host = result.get("hostname") or ""
+ if host not in allowed_hosts:
+ log.warning(
+ "captcha solved on an unexpected host %r; allowed: %s",
+ host, sorted(allowed_hosts))
+ return False
+ return True
except Exception:
log.exception("captcha verification request failed")
return False
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index 48d5a6e..39fdc52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -65,11 +65,25 @@ class JWTConfig:
class CaptchaConfig:
site_key: str = ""
secret_key: str = ""
+ # Hostnames a solved captcha may have come from, checked against the one
+ # `siteverify` reports. Empty means "do not check", which is right while
+ # the reCAPTCHA key does its own origin check — it is then already done.
+ #
+ # Set this when that check is turned off in the reCAPTCHA console, which is
+ # what the desktop client needs: its page is served from `app://meshbay`,
+ # so the hostname Google sees is not the hub's and never can be. See
+ # docs/captcha.md §6.
+ allowed_hosts: list[str] = field(default_factory=list)
@property
def enabled(self) -> bool:
return bool(self.site_key and self.secret_key)
+ @property
+ def host_check(self) -> frozenset[str] | None:
+ """The set to hand `verify_captcha`, or None for "do not check"."""
+ return frozenset(self.allowed_hosts) if self.allowed_hosts else None
+
@dataclass
class HubConfig:
@@ -106,6 +120,8 @@ def load_config(path: Path | None = None) -> HubConfig:
if cap := raw.get("captcha", {}):
cfg.captcha.site_key = cap.get("site_key", cfg.captcha.site_key)
cfg.captcha.secret_key = cap.get("secret_key", cfg.captcha.secret_key)
+ if hosts := cap.get("allowed_hosts"):
+ cfg.captcha.allowed_hosts = [str(h).strip() for h in hosts if str(h).strip()]
break
# Env var overrides
@@ -125,5 +141,7 @@ def load_config(path: Path | None = None) -> HubConfig:
cfg.captcha.site_key = captcha_site
if captcha_secret := os.environ.get("MESHBAY_CAPTCHA_SECRET_KEY"):
cfg.captcha.secret_key = captcha_secret
+ if captcha_hosts := os.environ.get("MESHBAY_CAPTCHA_ALLOWED_HOSTS"):
+ cfg.captcha.allowed_hosts = [h.strip() for h in captcha_hosts.split(",") if h.strip()]
return cfg
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"})
diff --git a/packages/meshbay-hub/tests/test_register_captcha.py b/packages/meshbay-hub/tests/test_register_captcha.py
index befc1e2..d319137 100644
--- a/packages/meshbay-hub/tests/test_register_captcha.py
+++ b/packages/meshbay-hub/tests/test_register_captcha.py
@@ -17,7 +17,7 @@ def captcha_on(client, monkeypatch):
monkeypatch.setattr(_cfg.captcha, "site_key", "test-site")
monkeypatch.setattr(_cfg.captcha, "secret_key", "test-secret")
- async def fake_verify(secret, token, remote_ip=None):
+ async def fake_verify(secret, token, remote_ip=None, allowed_hosts=None):
return token == "good-token"
monkeypatch.setattr("meshbay_hub.captcha.verify_captcha", fake_verify)