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.py37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/config.py18
3 files changed, 54 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 9b70b59..ece98cd 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -68,6 +68,7 @@ async def _verify_captcha_or_raise(token: str | None, request: Request) -> None:
_cfg.captcha.secret_key, # type: ignore[union-attr]
token,
request.client.host if request.client else None,
+ _cfg.captcha.host_check, # 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 faf7907..74a77b6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/captcha.py
+++ b/packages/meshbay-hub/src/meshbay_hub/captcha.py
@@ -10,8 +10,30 @@ VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
async def verify_captcha(
- secret_key: str, token: str, remote_ip: str | None = None,
+ 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
@@ -22,7 +44,18 @@ async def verify_captcha(
result = resp.json()
if not result.get("success"):
log.info("captcha rejected: %s", result.get("error-codes", []))
- return result.get("success", False)
+ 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
diff --git a/packages/meshbay-hub/src/meshbay_hub/config.py b/packages/meshbay-hub/src/meshbay_hub/config.py
index 48d5a6e..39fdc52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/config.py
+++ b/packages/meshbay-hub/src/meshbay_hub/config.py
@@ -65,11 +65,25 @@ class JWTConfig:
class CaptchaConfig:
site_key: str = ""
secret_key: str = ""
+ # Hostnames a solved captcha may have come from, checked against the one
+ # `siteverify` reports. Empty means "do not check", which is right while
+ # the reCAPTCHA key does its own origin check — it is then already done.
+ #
+ # Set this when that check is turned off in the reCAPTCHA console, which is
+ # what the desktop client needs: its page is served from `app://meshbay`,
+ # 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)
@property
def enabled(self) -> bool:
return bool(self.site_key and self.secret_key)
+ @property
+ def host_check(self) -> frozenset[str] | None:
+ """The set to hand `verify_captcha`, or None for "do not check"."""
+ return frozenset(self.allowed_hosts) if self.allowed_hosts else None
+
@dataclass
class HubConfig:
@@ -106,6 +120,8 @@ def load_config(path: Path | None = None) -> HubConfig:
if cap := raw.get("captcha", {}):
cfg.captcha.site_key = cap.get("site_key", cfg.captcha.site_key)
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()]
break
# Env var overrides
@@ -125,5 +141,7 @@ def load_config(path: Path | None = None) -> HubConfig:
cfg.captcha.site_key = captcha_site
if captcha_secret := os.environ.get("MESHBAY_CAPTCHA_SECRET_KEY"):
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()]
return cfg