"""reCAPTCHA v2 server-side verification.""" import logging import httpx log = logging.getLogger(__name__) VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify" async def verify_captcha( secret_key: str, token: str, remote_ip: str | None = None, allowed_hosts: frozenset[str] | set[str] | None = None, allow_unattributed: bool = False, ) -> 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://` 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. `allow_unattributed` covers the case that made the desktop client fail anyway. A solve from a page Google cannot attribute to a domain comes back with an **empty** hostname, not the host part of the origin — measured live: `app://meshbay` reports `''`, never `'meshbay'`. So an allowlist entry can never match it, and an empty string cannot be an allowlist entry either: a blank in a TOML list is a typo far more often than it is an intention, and the config parser drops blanks for that reason. What it admits is every non-web client, not only ours — a `file://` page or somebody else's Electron application report the same nothing. That is the same bar the desktop client's own origin would have been (`app://meshbay` is not a credential; any application can claim it), and it is a bar: the captcha still has to be *solved*, per token. What is given up is the origin restriction for non-web clients, not the captcha. """ payload: dict[str, str] = {"secret": secret_key, "response": token} if remote_ip: payload["remoteip"] = remote_ip try: async with httpx.AsyncClient(timeout=5) as client: resp = await client.post(VERIFY_URL, data=payload) resp.raise_for_status() result = resp.json() if not result.get("success"): log.info("captcha rejected: %s", result.get("error-codes", [])) 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. It is how the empty # one was found. host = result.get("hostname") or "" if not host: if not allow_unattributed: log.warning( "captcha solved on a host Google did not attribute; " "set captcha.allow_unattributed_host to admit " "non-web clients such as the desktop application") return False elif 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