diff --git a/paskia/domains.py b/paskia/domains.py index c9824e4..c4700d8 100644 --- a/paskia/domains.py +++ b/paskia/domains.py @@ -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: diff --git a/paskia/sansio.py b/paskia/sansio.py index f7cf44b..e5c36f5 100644 --- a/paskia/sansio.py +++ b/paskia/sansio.py @@ -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.""" diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index 9a46439..2aa1122 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -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 diff --git a/tests/test_domains.py b/tests/test_domains.py index ab44763..3517eae 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -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"])