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
|