harden(passkeys): accept only a well-formed http(s) Origin for RPID derivation
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.
This commit is contained in:
1 parent
0f76602790
commit
289b539bc4
1 file changed
+19
-3
+19
-3
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user