aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/captcha.py28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py11
3 files changed, 38 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 ece98cd..56208a0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -69,6 +69,7 @@ async def _verify_captcha_or_raise(token: str | None, request: Request) -> None:
token,
request.client.host if request.client else None,
_cfg.captcha.host_check, # type: ignore[union-attr]
+ _cfg.captcha.allow_unattributed_host, # 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 74a77b6..cf33d52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/captcha.py
+++ b/packages/meshbay-hub/src/meshbay_hub/captcha.py
@@ -14,6 +14,7 @@ async def verify_captcha(
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.
@@ -33,6 +34,21 @@ async def verify_captcha(
`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:
@@ -48,9 +64,17 @@ async def verify_captcha(
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.
+ # whose origin they have not seen before. It is how the empty
+ # one was found.
host = result.get("hostname") or ""
- if host not in allowed_hosts:
+ 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))
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index 39fdc52..e61e69e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -74,6 +74,13 @@ class CaptchaConfig:
# 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)
+ # A solve from a page Google cannot attribute to a domain reports an empty
+ # hostname — `app://meshbay` does, measured live, and so does any other
+ # non-web client. No `allowed_hosts` entry can match that, and a blank
+ # entry is not the answer: the parser drops blanks because in a TOML list
+ # a blank is a typo far more often than an intention. Hence a named flag,
+ # which also states the trade at the place it is made.
+ allow_unattributed_host: bool = False
@property
def enabled(self) -> bool:
@@ -122,6 +129,8 @@ def load_config(path: Path | None = None) -> HubConfig:
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()]
+ if "allow_unattributed_host" in cap:
+ cfg.captcha.allow_unattributed_host = bool(cap["allow_unattributed_host"])
break
# Env var overrides
@@ -143,5 +152,7 @@ def load_config(path: Path | None = None) -> HubConfig:
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()]
+ if unattributed := os.environ.get("MESHBAY_CAPTCHA_ALLOW_UNATTRIBUTED_HOST"):
+ cfg.captcha.allow_unattributed_host = unattributed.lower() in ("1", "true", "yes")
return cfg