Validate origin hostname shape: no empty labels (leading/trailing/double dots rejected) in stored config, sanitize, and Passkey construction
This commit is contained in:
+9
-4
@@ -218,7 +218,9 @@ def validate_config(
|
||||
)
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = hostutil.wildcard_base(key)
|
||||
if not base or not hostutil.is_subdomain(base, rp_id):
|
||||
if not base or not hostutil.is_valid_hostname(base):
|
||||
raise ValueError(f"Invalid wildcard origin: '{key}'")
|
||||
if not hostutil.is_subdomain(base, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{key}' is a wildcard outside the rp-id "
|
||||
f"domain '{rp_id}' — related origins must be "
|
||||
@@ -228,7 +230,7 @@ def validate_config(
|
||||
raise ValueError(f"Wildcard origin '{key}' cannot be the auth host")
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
if not hn or not hostutil.is_valid_hostname(hn):
|
||||
raise ValueError(f"Invalid origin: '{key}'")
|
||||
if hostutil.is_subdomain(hn, rp_id):
|
||||
if is_auth:
|
||||
@@ -322,7 +324,10 @@ def sanitize_config(
|
||||
continue
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = hostutil.wildcard_base(key)
|
||||
if not base or not hostutil.is_subdomain(base, rp_id):
|
||||
if not base or not hostutil.is_valid_hostname(base):
|
||||
warn(f"Domain '{rp_id}': invalid wildcard origin '{key}' dropped")
|
||||
continue
|
||||
if not hostutil.is_subdomain(base, rp_id):
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '{key}' is a wildcard "
|
||||
"outside the rp-id domain — dropped (related origins "
|
||||
@@ -338,7 +343,7 @@ def sanitize_config(
|
||||
origins[key] = props
|
||||
continue
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if not hn:
|
||||
if not hn or not hostutil.is_valid_hostname(hn):
|
||||
warn(f"Domain '{rp_id}': invalid origin '{key}' dropped")
|
||||
continue
|
||||
if is_auth:
|
||||
|
||||
+7
-2
@@ -81,6 +81,8 @@ class Passkey:
|
||||
for o in origins or []:
|
||||
if hostutil.is_wildcard_pattern(o):
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
if hostname and not hostutil.is_valid_hostname(hostname):
|
||||
raise ValueError(f"Origin '{o}' has a malformed hostname")
|
||||
else:
|
||||
self._validate_origin_url(o)
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
@@ -113,9 +115,12 @@ class Passkey:
|
||||
|
||||
@staticmethod
|
||||
def _validate_origin_url(origin: str) -> None:
|
||||
"""Validate that an origin URL is well-formed (has a hostname)."""
|
||||
if not hostutil.origin_hostname(origin):
|
||||
"""Validate that an origin URL is well-formed (has a valid hostname)."""
|
||||
hostname = hostutil.origin_hostname(origin)
|
||||
if not hostname:
|
||||
raise ValueError(f"Invalid origin URL: no hostname found in '{origin}'")
|
||||
if not hostutil.is_valid_hostname(hostname):
|
||||
raise ValueError(f"Invalid origin URL: malformed hostname in '{origin}'")
|
||||
|
||||
def _origin_in_subtree(self, origin: str) -> bool:
|
||||
"""Check whether an origin's hostname is the rp-id or its subdomain."""
|
||||
|
||||
@@ -18,6 +18,13 @@ def validate_rp_id(rp_id: str) -> None:
|
||||
raise ValueError(f"rp_id '{rp_id}' is not a valid domain name")
|
||||
|
||||
|
||||
def is_valid_hostname(hostname: str) -> bool:
|
||||
"""Check hostname shape: dot-separated alphanumeric/hyphen labels —
|
||||
no empty labels, so no leading/trailing or double dots ('.localhost',
|
||||
'localhost.', 'a..b.com' are all malformed)."""
|
||||
return bool(_RP_ID_RE.match(hostname))
|
||||
|
||||
|
||||
def is_wildcard_pattern(value: str) -> bool:
|
||||
"""Check whether an origins entry is a wildcard pattern like
|
||||
'*.example.com' (one subdomain level) or '**.example.com' (the base
|
||||
|
||||
@@ -222,6 +222,20 @@ class TestValidateConfig:
|
||||
Config(domains={"a.com": DomainConfig(origins={"*": True})})
|
||||
)
|
||||
|
||||
def test_malformed_origin_hostname_rejected(self):
|
||||
"""No empty hostname labels — leading, trailing and double dots
|
||||
are invalid, in concrete entries and wildcard bases alike."""
|
||||
for key in (".a.com", "a..com", "a.com.", "http://.a.com:8080"):
|
||||
with pytest.raises(ValueError, match="Invalid origin"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={key: True})})
|
||||
)
|
||||
for key in ("*..a.com", "**..a.com"):
|
||||
with pytest.raises(ValueError, match="Invalid wildcard origin"):
|
||||
domains.validate_config(
|
||||
Config(domains={"a.com": DomainConfig(origins={key: True})})
|
||||
)
|
||||
|
||||
def test_subdomain_entry_is_in_domain(self):
|
||||
"""An entry within the rp-id domain is an ordinary in-domain
|
||||
sign-in site, never a related origin."""
|
||||
@@ -395,6 +409,27 @@ class TestSanitizeConfig:
|
||||
assert config.domains["a.com"].origins == {}
|
||||
assert warnings
|
||||
|
||||
def test_malformed_hostname_dropped(self):
|
||||
"""Empty hostname labels (leading/trailing/double dots) are dropped,
|
||||
from concrete entries and wildcard bases alike."""
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(
|
||||
domains={
|
||||
"a.com": DomainConfig(
|
||||
origins={
|
||||
".a.com": True,
|
||||
"a.com.": True,
|
||||
"**.a..com": True,
|
||||
"ok.a.com": True,
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
assert list(config.domains["a.com"].origins) == ["ok.a.com"]
|
||||
assert len(warnings) == 3
|
||||
domains.validate_config(config) # sanitized config is strict-clean
|
||||
|
||||
def test_invalid_rp_id_domain_dropped(self):
|
||||
config, warnings = domains.sanitize_config(
|
||||
Config(domains={"not a domain!": DomainConfig(), "ok.com": DomainConfig()})
|
||||
@@ -558,6 +593,15 @@ class TestOriginValidation:
|
||||
with pytest.raises(ValueError, match="Invalid origin"):
|
||||
Passkey(rp_id="example.com", origins=["*"])
|
||||
|
||||
def test_malformed_hostname_rejected(self):
|
||||
"""Leading/trailing/double dots are invalid in any entry form."""
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", origins=["https://.example.com"])
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", origins=["**.a..example.com"])
|
||||
with pytest.raises(ValueError, match="malformed hostname"):
|
||||
Passkey(rp_id="example.com", related_origins=["https://other..com"])
|
||||
|
||||
def test_localhost_wildcard_matches_any_scheme_and_port(self):
|
||||
"""Under localhost, wildcards match any scheme and any port."""
|
||||
p = Passkey(rp_id="localhost", origins=["**.localhost"])
|
||||
|
||||
Reference in New Issue
Block a user