From 289b539bc49dbb618bc350eb6c3f1dfae7a84d1d Mon Sep 17 00:00:00 2001 From: n Date: Tue, 8 Sep 2026 01:31:26 +0000 Subject: [PATCH] harden(passkeys): accept only a well-formed http(s) Origin for RPID derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on #6772. Preferring Origin over Host for the WebAuthn RPID is correct (the RPID must match the page origin, not the backend's), and it is not a trust decision: the authenticator only releases a credential whose RPID is a registrable suffix of the real page origin, clientDataJSON.origin must equal the stored origin, rpIdHash must match the stored RPID, and the assertion signature is verified against the stored credential public key. Still, only accept a syntactically valid http(s) Origin carrying a hostname, and rebuild the stored origin from the parsed scheme/host/port instead of echoing the raw header — so a malformed, non-http (javascript:/file:), or userinfo-bearing Origin falls through to the Host-derived path rather than being stored verbatim. --- api/passkeys.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/api/passkeys.py b/api/passkeys.py index 28631b3d..220d1484 100644 --- a/api/passkeys.py +++ b/api/passkeys.py @@ -187,13 +187,29 @@ def rp_context(handler) -> tuple[str, str]: # useless for deriving the RPID, while Origin still carries the origin the # browser is actually on. Prefer Origin's hostname: WebAuthn's RPID-origin # check compares against the page's origin, not the backend's. + # + # Origin is client-supplied, so this is deliberately NOT a trust decision: + # it only selects which name the ceremony is scoped to. The actual security + # gates are unchanged and live elsewhere — the authenticator will only + # release a credential whose RPID is a registrable suffix of the real page + # origin, `_client_data()` requires clientDataJSON.origin to equal the + # origin stored with the challenge, `_parse_auth_data()` compares the + # authenticator's rpIdHash against that same stored RPID, and the assertion + # signature is verified against the stored credential public key. A forged + # Origin therefore yields a self-consistent ceremony that still cannot + # produce a valid signature. + # + # Accept it only as a syntactically valid http(s) origin with a hostname, + # and rebuild the origin string from the parsed parts rather than echoing + # the raw header, so a malformed or non-http Origin falls through to Host. browser_origin = handler.headers.get("Origin", "") if browser_origin: try: from urllib.parse import urlparse - parsed = urlparse(browser_origin) - if parsed.hostname: - return parsed.hostname, browser_origin + parsed = urlparse(browser_origin.strip()) + if parsed.scheme in ("http", "https") and parsed.hostname: + netloc = parsed.hostname if parsed.port is None else f"{parsed.hostname}:{parsed.port}" + return parsed.hostname, f"{parsed.scheme}://{netloc}" except Exception: pass # Fallback: derive from Host header (direct/internal access)