From 52f3b26630a6cc7326b19dfe84151c4b6171a579 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 15:15:30 +0000 Subject: [PATCH] Wildcard origins follow the shell-glob convention: **. for apex+any depth, *. for one level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '**.example.com' covers the apex and subdomains at any depth; '*.example.com' covers exactly one subdomain level (neither apex nor deeper) — analogous to permission scope wildcards, and sidestepping the DNS/TLS/nginx ambiguity around '*.'. This also allows excluding the apex where wanted. The seeded/default entry becomes '**.{rp-id}' (init, add-domain, legacy empty-origins conversion, branch-era '*' sanitize rewrite). --- e2e/tests/50-multidomain.spec.ts | 10 +++--- paskia/__main__.py | 6 ++-- paskia/db/legacy.py | 2 +- paskia/db/structs.py | 13 ++++---- paskia/domains.py | 13 ++++---- paskia/sansio.py | 40 ++++++++++++++++-------- paskia/util/hostutil.py | 23 ++++++++++---- tests/conftest.py | 2 +- tests/test_admin.py | 4 +-- tests/test_cli.py | 4 +-- tests/test_domains.py | 53 ++++++++++++++++++++++---------- 11 files changed, 108 insertions(+), 62 deletions(-) diff --git a/e2e/tests/50-multidomain.spec.ts b/e2e/tests/50-multidomain.spec.ts index be9d8e6..60dd23e 100644 --- a/e2e/tests/50-multidomain.spec.ts +++ b/e2e/tests/50-multidomain.spec.ts @@ -82,13 +82,13 @@ test.describe('Multi-domain E2E', () => { const domains = await list.json() expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost']) const localhostDomain = domains.find((r: any) => r.rp_id === 'localhost') - expect(localhostDomain.origins).toEqual({ '*.localhost': true }) + expect(localhostDomain.origins).toEqual({ '**.localhost': true }) // Add a related origin (unrelated domain) to the localhost domain — // same origins table; classification is derived from the rp-id const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, { headers, - data: { rp_name: '', origins: { '*.localhost': true, 'app.example.com': true } }, + data: { rp_name: '', origins: { '**.localhost': true, 'app.example.com': true } }, }) expect(patch.ok()).toBeTruthy() @@ -109,7 +109,7 @@ test.describe('Multi-domain E2E', () => { // Restore: back to the pristine seeded state for later tests const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, { headers, - data: { rp_name: '', origins: { '*.localhost': true } }, + data: { rp_name: '', origins: { '**.localhost': true } }, }) expect(restore.ok()).toBeTruthy() const after = await page.request.get(`${baseUrl}/.well-known/webauthn`) @@ -138,7 +138,7 @@ test.describe('Multi-domain E2E', () => { // *.localhost hostname to loopback, so the auth host is reachable. const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, { headers, - data: { rp_name: '', origins: { [`http://${authHost}`]: { auth_host: true }, '*.test.localhost': true } }, + data: { rp_name: '', origins: { [`http://${authHost}`]: { auth_host: true }, '**.test.localhost': true } }, }) expect(patch.ok()).toBeTruthy() @@ -168,7 +168,7 @@ test.describe('Multi-domain E2E', () => { // test.localhost, and an empty table would allow nothing) const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, { headers, - data: { rp_name: '', origins: { '*.test.localhost': true } }, + data: { rp_name: '', origins: { '**.test.localhost': true } }, }) expect(restore.ok()).toBeTruthy() } diff --git a/paskia/__main__.py b/paskia/__main__.py index 4c2c97e..4377a8d 100644 --- a/paskia/__main__.py +++ b/paskia/__main__.py @@ -91,7 +91,7 @@ def _init_add_domain(db_path: Path, rp_id: str, rp_name: str | None, listen) -> if listen is not None: data.config.listen = listen return f"Updated domain {rp_id}" - new = DomainConfig(rp_name=rp_name, origins={f"*.{rp_id}": True}) + new = DomainConfig(rp_name=rp_name, origins={f"**.{rp_id}": True}) try: validate_config( Config( @@ -134,13 +134,13 @@ def cmd_init(args: argparse.Namespace) -> None: ) # Only rp-id and rp-name are bootstrap-time configuration; the new - # domain starts with its whole subtree allowed ('*.{rp-id}') and + # domain starts with its whole subtree allowed ('**.{rp-id}') and # everything else (origin allow-list, auth host, related domains) is # set up afterwards via the admin interface. The bootstrap rp-name # exists so the very first admin registration ceremony already shows # the correct name. config = Config( - domains={rp_id: DomainConfig(rp_name=rp_name, origins={f"*.{rp_id}": True})}, + domains={rp_id: DomainConfig(rp_name=rp_name, origins={f"**.{rp_id}": True})}, listen=listen, ) try: diff --git a/paskia/db/legacy.py b/paskia/db/legacy.py index b2e4cb8..1207fbf 100644 --- a/paskia/db/legacy.py +++ b/paskia/db/legacy.py @@ -123,7 +123,7 @@ def convert_legacy_database(src: Path, dst: Path) -> Config: if not origins: # Legacy semantics: no origins configured = the whole rp-id domain # allowed. The new format requires explicit entries. - origins[f"*.{rp_id}"] = True + origins[f"**.{rp_id}"] = True new_config = Config( domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)}, diff --git a/paskia/db/structs.py b/paskia/db/structs.py index 9021ecc..69efcc3 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -630,11 +630,12 @@ class DomainConfig(msgspec.Struct, omit_defaults=True): domain are in-domain sign-in sites, entries outside it are related origins (WebAuthn Related Origin Requests — individual hosts only, no wildcards). Keys are hosts without the https:// scheme - ("app.example.com"), wildcard patterns under the rp-id - ("*.example.com" — the base domain and its subdomains over https only, - any scheme and port under localhost), or full origins - ("http://localhost:8080", "https://app2.com"). An empty dict means - nothing is allowed — list sites explicitly. Ordering carries no + ("app.example.com"), wildcard patterns under the rp-id following the + shell-glob convention ("**.example.com" — the base domain and its + subdomains at any depth; "*.example.com" — exactly one subdomain + level; https only, any scheme and port under localhost), or full + origins ("http://localhost:8080", "https://app2.com"). An empty dict + means nothing is allowed — list sites explicitly. Ordering carries no meaning — display order is decided by the UI. """ @@ -651,7 +652,7 @@ class Config(msgspec.Struct, omit_defaults=True): domains: dict[str, DomainConfig] = msgspec.field( default_factory=lambda: { - "localhost": DomainConfig(origins={"*.localhost": True}) + "localhost": DomainConfig(origins={"**.localhost": True}) } ) listen: list[str] | None = None # Process-global listen endpoints diff --git a/paskia/domains.py b/paskia/domains.py index 6e6a498..c9824e4 100644 --- a/paskia/domains.py +++ b/paskia/domains.py @@ -48,7 +48,8 @@ def origin_key(origin: str) -> str: """ key = origin.removeprefix("https://").rstrip("/") if hostutil.is_wildcard_pattern(key): - return "*." + key[2:].rstrip(".").lower() + prefix = "**." if key.startswith("**.") else "*." + return prefix + key[len(prefix) :].rstrip(".").lower() if "://" not in key: key = key.rstrip(".") return key.lower() @@ -213,10 +214,10 @@ def validate_config( is_auth = isinstance(props, OriginEntry) and props.auth_host if key == "*": raise ValueError( - f"Origin '*' is not allowed — list '*.{rp_id}' explicitly" + f"Origin '*' is not allowed — list '**.{rp_id}' explicitly" ) if hostutil.is_wildcard_pattern(key): - base = key[2:].rstrip(".") + base = hostutil.wildcard_base(key) if not base or not hostutil.is_subdomain(base, rp_id): raise ValueError( f"Origin '{key}' is a wildcard outside the rp-id " @@ -314,13 +315,13 @@ def sanitize_config( props = True # canonicalize junk/empty entries to presence-only if key == "*": warn( - f"Domain '{rp_id}': origin '*' rewritten as '*.{rp_id}'" + f"Domain '{rp_id}': origin '*' rewritten as '**.{rp_id}'" + (" — auth host mark cleared" if is_auth else "") ) - origins[f"*.{rp_id}"] = True + origins[f"**.{rp_id}"] = True continue if hostutil.is_wildcard_pattern(key): - base = key[2:].rstrip(".") + base = hostutil.wildcard_base(key) if not base or not hostutil.is_subdomain(base, rp_id): warn( f"Domain '{rp_id}': origin '{key}' is a wildcard " diff --git a/paskia/sansio.py b/paskia/sansio.py index 0490687..f7cf44b 100644 --- a/paskia/sansio.py +++ b/paskia/sansio.py @@ -56,12 +56,15 @@ class Passkey: rp_id: Your security domain (e.g. "example.com") rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators. origins: Allow-list of sign-in site origins within the rp-id domain - (e.g. ["https://app.example.com"]); wildcard patterns like - "*.example.com" match the base domain and its subdomains - over https only — except under localhost ("*.localhost"), - which matches any scheme and any port. Exact entries match - scheme, host and port. An empty list (the default) allows - nothing — pass ["*.{rp-id}"] to allow the whole domain. + (e.g. ["https://app.example.com"]); wildcard patterns + follow the shell-glob convention: "**.example.com" matches + the base domain and its subdomains at any depth, while + "*.example.com" matches exactly one subdomain level — + over https only, except under localhost + ("**.localhost"), which matches any scheme and any port. + Exact entries match scheme, host and port. An empty list + (the default) allows nothing — pass ["**.{rp-id}"] to + allow the whole domain. related_origins: Origins on unrelated domains that may assert this rp-id (WebAuthn Related Origin Requests). Always additive. supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256). @@ -122,19 +125,30 @@ class Passkey: def _allowlisted(self, origin: str) -> bool: """Check an in-domain origin against the allow-list. - An entry matches exactly; a wildcard pattern ('*.example.com') - matches the base domain and any subdomain of it over https only, - except under localhost ('*.localhost'), which matches any scheme - and any port. + An entry matches exactly. A wildcard pattern matches hostnames + under its base: '**.example.com' covers the base domain itself and + subdomains at any depth, while '*.example.com' covers exactly one + subdomain level (neither the apex nor deeper levels) — the + shell-glob convention, analogous to permission scope wildcards. + Wildcards match over https only, except under localhost + ('**.localhost'), which matches any scheme and any port. """ if origin in self.allowed_origins: return True hostname = hostutil.origin_hostname(origin) for entry in self.allowed_origins: - if not hostutil.is_wildcard_pattern(entry): + base = hostutil.wildcard_base(entry) + if not base or not hostname: continue - base = entry[2:].rstrip(".") - if not hostutil.is_subdomain(hostname, base): + if entry.startswith("**."): + matched = hostutil.is_subdomain(hostname, base) + else: + # Exactly one subdomain level below the base + matched = ( + hostname.endswith(f".{base}") + and "." not in hostname[: -len(base) - 1] + ) + if not matched: continue if hostutil.is_subdomain(base, "localhost"): return True # localhost: any scheme, any port diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index 1deaff4..9a46439 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -19,15 +19,26 @@ def validate_rp_id(rp_id: str) -> None: def is_wildcard_pattern(value: str) -> bool: - """Check whether an origins entry is a wildcard pattern like '*.example.com'.""" - return value.startswith("*.") + """Check whether an origins entry is a wildcard pattern like + '*.example.com' (one subdomain level) or '**.example.com' (the base + domain and any depth of subdomains).""" + return value.startswith("*.") or value.startswith("**.") + + +def wildcard_base(pattern: str) -> str | None: + """Base domain of a wildcard pattern; None if not a wildcard.""" + if pattern.startswith("**."): + return pattern[3:].rstrip(".") or None + if pattern.startswith("*."): + return pattern[2:].rstrip(".") or None + return None def normalize_origin(origin: str) -> str: """Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes. - Wildcard patterns ('*.example.com') pass through unchanged — they are - allow-list entries, not concrete origins. + Wildcard patterns ('*.example.com', '**.example.com') pass through + unchanged — they are allow-list entries, not concrete origins. """ if is_wildcard_pattern(origin): return origin.rstrip("/.") @@ -41,8 +52,8 @@ def origin_hostname(origin: str) -> str | None: For wildcard patterns the base domain is returned. """ - if is_wildcard_pattern(origin): - return origin[2:].rstrip(".").lower() or None + if base := wildcard_base(origin): + return base.lower() return urlparse(origin).hostname diff --git a/tests/conftest.py b/tests/conftest.py index 3518120..32a0a91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,7 +96,7 @@ async def test_db() -> AsyncGenerator[DB]: admin_name="Test Admin", config=Config( domains={ - TEST_RP_ID: DomainConfig(origins={f"*.{TEST_RP_ID}": True}) + TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True}) } ), ) diff --git a/tests/test_admin.py b/tests/test_admin.py index f7b1e6c..f29c64e 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1828,7 +1828,7 @@ class TestDomains: assert len(data) == 1 domain = data[0] assert domain["rp_id"] == "localhost" - assert domain["origins"] == {"*.localhost": True} + assert domain["origins"] == {"**.localhost": True} assert "related" not in domain assert domain["auth_host"] is None assert domain["site_url"] == "http://localhost:4401" @@ -2022,7 +2022,7 @@ class TestDomains: ) assert r.status_code == 200 - # Plain '*' is rejected — wildcards must be explicit ('*.another.com') + # Plain '*' is rejected — wildcards must be explicit ('**.another.com') r = await client.post( "/auth/api/admin/domains/", json={"rp_id": "star.com", "origins": {"*": True}}, diff --git a/tests/test_cli.py b/tests/test_cli.py index 0c99622..11bb27c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -87,7 +87,7 @@ def test_init_defaults(run_cli, tmp_path): config = stored_config(tmp_path) assert list(config.domains) == ["localhost"] assert config.domains["localhost"].rp_name is None - assert config.domains["localhost"].origins == {"*.localhost": True} + assert config.domains["localhost"].origins == {"**.localhost": True} assert config.listen is None @@ -97,7 +97,7 @@ def test_init_full_options(run_cli, tmp_path): config = stored_config(tmp_path) domain = config.domains["example.com"] assert domain.rp_name == "Example Corp" - assert domain.origins == {"*.example.com": True} + assert domain.origins == {"**.example.com": True} assert config.listen == ["4402"] diff --git a/tests/test_domains.py b/tests/test_domains.py index 5889619..ab44763 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -59,7 +59,7 @@ ROR_CONFIG = Config( "app.com": True, # related origin (outside the rp-id domain) }, ), - "pro.com": DomainConfig(rp_name="Pro", origins={"*.pro.com": True}), + "pro.com": DomainConfig(rp_name="Pro", origins={"**.pro.com": True}), } ) @@ -232,13 +232,16 @@ class TestValidateConfig: def test_wildcard_outside_rp_id_rejected(self): """Related origins are individual hosts; wildcards must stay within - the rp-id domain.""" + the rp-id domain. Both wildcard forms are accepted in-domain.""" domains.validate_config( Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})}) ) + domains.validate_config( + Config(domains={"a.com": DomainConfig(origins={"**.a.com": True})}) + ) with pytest.raises(ValueError, match="wildcard outside the rp-id"): domains.validate_config( - Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})}) + Config(domains={"a.com": DomainConfig(origins={"**.b.com": True})}) ) def test_wildcard_auth_host_rejected(self): @@ -365,13 +368,13 @@ class TestSanitizeConfig: domains.validate_config(config) # sanitized config is strict-clean def test_star_origin_rewritten_explicit(self): - """Branch-era '*' shorthand is rewritten to '*.{rp-id}'; an auth + """Branch-era '*' shorthand is rewritten to '**.{rp-id}'; an auth mark on it is cleared.""" config, warnings = domains.sanitize_config( Config(domains={"a.com": DomainConfig(origins={"*": True})}) ) - assert config.domains["a.com"].origins == {"*.a.com": True} - assert any("'*.'" in w or "*." in w for w in warnings) + assert config.domains["a.com"].origins == {"**.a.com": True} + assert any("**." in w for w in warnings) domains.validate_config(config) config, warnings = domains.sanitize_config( @@ -381,7 +384,7 @@ class TestSanitizeConfig: } ) ) - assert config.domains["a.com"].origins == {"*.a.com": True} + assert config.domains["a.com"].origins == {"**.a.com": True} assert any("mark cleared" in w for w in warnings) domains.validate_config(config) @@ -521,16 +524,31 @@ class TestOriginValidation: with pytest.raises(ValueError, match="not allowed"): p.validate_origin("https://other.com") - def test_wildcard_entry_matches_subtree(self): - p = Passkey(rp_id="example.com", origins=["*.example.com"]) - assert p.validate_origin("https://example.com") - assert p.validate_origin("https://app.example.com") + def test_double_star_matches_apex_and_any_depth(self): + """'**.example.com' covers the apex and subdomains at any depth + (the shell-glob convention).""" + p = Passkey(rp_id="example.com", origins=["**.example.com"]) + assert p.validate_origin("https://example.com") # apex + assert p.validate_origin("https://app.example.com") # one level + assert p.validate_origin("https://a.b.c.example.com") # any depth + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://anotherexample.com") with pytest.raises(ValueError, match="not allowed"): p.validate_origin("https://other.com") - def test_wildcard_is_https_only(self): - """A '*.example.com' entry does not fall back to other schemes.""" + def test_single_star_matches_exactly_one_level(self): + """'*.example.com' covers exactly one subdomain level — neither the + apex nor deeper levels.""" p = Passkey(rp_id="example.com", origins=["*.example.com"]) + assert p.validate_origin("https://app.example.com") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://example.com") # apex excluded + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://a.b.example.com") # too deep + + def test_wildcard_is_https_only(self): + """A '**.example.com' entry does not fall back to other schemes.""" + p = Passkey(rp_id="example.com", origins=["**.example.com"]) with pytest.raises(ValueError, match="not allowed"): p.validate_origin("http://example.com") with pytest.raises(ValueError, match="not allowed"): @@ -542,9 +560,10 @@ class TestOriginValidation: 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"]) + p = Passkey(rp_id="localhost", origins=["**.localhost"]) assert p.validate_origin("http://localhost:8080") assert p.validate_origin("http://app.localhost:3000") + assert p.validate_origin("http://a.b.localhost:3000") assert p.validate_origin("https://localhost") def test_exact_entry_matches_scheme_and_port(self): @@ -556,7 +575,7 @@ class TestOriginValidation: p.validate_origin("http://localhost:4404") def test_sub_wildcard_matches_only_its_subtree(self): - p = Passkey(rp_id="example.com", origins=["*.app.example.com"]) + p = Passkey(rp_id="example.com", origins=["**.app.example.com"]) assert p.validate_origin("https://app.example.com") assert p.validate_origin("https://www.app.example.com") with pytest.raises(ValueError, match="not allowed"): @@ -870,13 +889,13 @@ class TestLegacyConversion: def test_convert_empty_origins_seeds_wildcard(self, tmp_path): """Legacy 'no origins' meant the whole rp-id domain; the new format - makes that explicit as '*.{rp-id}'.""" + makes that explicit as '**.{rp-id}'.""" src_file = tmp_path / "main.db" asyncio.run( _write_legacy(src_file, LegacyConfig(rp_id="example.com", rp_name="Ex")) ) config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb") - assert config.domains["example.com"].origins == {"*.example.com": True} + assert config.domains["example.com"].origins == {"**.example.com": True} # -------------------------------------------------------------------------