diff options
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/users.py | 1 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/captcha.py | 37 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/config.py | 18 |
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 |