aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
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 /packages/meshbay-hub/src
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
Diffstat (limited to 'packages/meshbay-hub/src')
-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
3 files changed, 54 insertions, 2 deletions
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