aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/captcha.py
blob: 74a77b62151c601693c5cf5d660c741b9ff205d7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
"""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,
) -> 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
    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.
                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