aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/captcha.py
blob: cf33d520f600b484edad7f45af9e0db02db416a8 (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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
"""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://<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.

    `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