Wildcard origins follow the shell-glob convention: **. for apex+any depth, *. for one level
'**.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).
This commit is contained in:
@@ -82,13 +82,13 @@ test.describe('Multi-domain E2E', () => {
|
|||||||
const domains = await list.json()
|
const domains = await list.json()
|
||||||
expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost'])
|
expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost'])
|
||||||
const localhostDomain = domains.find((r: any) => r.rp_id === '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 —
|
// Add a related origin (unrelated domain) to the localhost domain —
|
||||||
// same origins table; classification is derived from the rp-id
|
// same origins table; classification is derived from the rp-id
|
||||||
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
|
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
|
||||||
headers,
|
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()
|
expect(patch.ok()).toBeTruthy()
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ test.describe('Multi-domain E2E', () => {
|
|||||||
// Restore: back to the pristine seeded state for later tests
|
// Restore: back to the pristine seeded state for later tests
|
||||||
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
|
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
|
||||||
headers,
|
headers,
|
||||||
data: { rp_name: '', origins: { '*.localhost': true } },
|
data: { rp_name: '', origins: { '**.localhost': true } },
|
||||||
})
|
})
|
||||||
expect(restore.ok()).toBeTruthy()
|
expect(restore.ok()).toBeTruthy()
|
||||||
const after = await page.request.get(`${baseUrl}/.well-known/webauthn`)
|
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.
|
// *.localhost hostname to loopback, so the auth host is reachable.
|
||||||
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
|
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
|
||||||
headers,
|
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()
|
expect(patch.ok()).toBeTruthy()
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ test.describe('Multi-domain E2E', () => {
|
|||||||
// test.localhost, and an empty table would allow nothing)
|
// test.localhost, and an empty table would allow nothing)
|
||||||
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
|
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, {
|
||||||
headers,
|
headers,
|
||||||
data: { rp_name: '', origins: { '*.test.localhost': true } },
|
data: { rp_name: '', origins: { '**.test.localhost': true } },
|
||||||
})
|
})
|
||||||
expect(restore.ok()).toBeTruthy()
|
expect(restore.ok()).toBeTruthy()
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -91,7 +91,7 @@ def _init_add_domain(db_path: Path, rp_id: str, rp_name: str | None, listen) ->
|
|||||||
if listen is not None:
|
if listen is not None:
|
||||||
data.config.listen = listen
|
data.config.listen = listen
|
||||||
return f"Updated domain {rp_id}"
|
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:
|
try:
|
||||||
validate_config(
|
validate_config(
|
||||||
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
|
# 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
|
# everything else (origin allow-list, auth host, related domains) is
|
||||||
# set up afterwards via the admin interface. The bootstrap rp-name
|
# set up afterwards via the admin interface. The bootstrap rp-name
|
||||||
# exists so the very first admin registration ceremony already shows
|
# exists so the very first admin registration ceremony already shows
|
||||||
# the correct name.
|
# the correct name.
|
||||||
config = Config(
|
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,
|
listen=listen,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+1
-1
@@ -123,7 +123,7 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
|
|||||||
if not origins:
|
if not origins:
|
||||||
# Legacy semantics: no origins configured = the whole rp-id domain
|
# Legacy semantics: no origins configured = the whole rp-id domain
|
||||||
# allowed. The new format requires explicit entries.
|
# allowed. The new format requires explicit entries.
|
||||||
origins[f"*.{rp_id}"] = True
|
origins[f"**.{rp_id}"] = True
|
||||||
|
|
||||||
new_config = Config(
|
new_config = Config(
|
||||||
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
|
domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)},
|
||||||
|
|||||||
@@ -630,11 +630,12 @@ class DomainConfig(msgspec.Struct, omit_defaults=True):
|
|||||||
domain are in-domain sign-in sites, entries outside it are related
|
domain are in-domain sign-in sites, entries outside it are related
|
||||||
origins (WebAuthn Related Origin Requests — individual hosts only,
|
origins (WebAuthn Related Origin Requests — individual hosts only,
|
||||||
no wildcards). Keys are hosts without the https:// scheme
|
no wildcards). Keys are hosts without the https:// scheme
|
||||||
("app.example.com"), wildcard patterns under the rp-id
|
("app.example.com"), wildcard patterns under the rp-id following the
|
||||||
("*.example.com" — the base domain and its subdomains over https only,
|
shell-glob convention ("**.example.com" — the base domain and its
|
||||||
any scheme and port under localhost), or full origins
|
subdomains at any depth; "*.example.com" — exactly one subdomain
|
||||||
("http://localhost:8080", "https://app2.com"). An empty dict means
|
level; https only, any scheme and port under localhost), or full
|
||||||
nothing is allowed — list sites explicitly. Ordering carries no
|
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.
|
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(
|
domains: dict[str, DomainConfig] = msgspec.field(
|
||||||
default_factory=lambda: {
|
default_factory=lambda: {
|
||||||
"localhost": DomainConfig(origins={"*.localhost": True})
|
"localhost": DomainConfig(origins={"**.localhost": True})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
listen: list[str] | None = None # Process-global listen endpoints
|
listen: list[str] | None = None # Process-global listen endpoints
|
||||||
|
|||||||
+7
-6
@@ -48,7 +48,8 @@ def origin_key(origin: str) -> str:
|
|||||||
"""
|
"""
|
||||||
key = origin.removeprefix("https://").rstrip("/")
|
key = origin.removeprefix("https://").rstrip("/")
|
||||||
if hostutil.is_wildcard_pattern(key):
|
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:
|
if "://" not in key:
|
||||||
key = key.rstrip(".")
|
key = key.rstrip(".")
|
||||||
return key.lower()
|
return key.lower()
|
||||||
@@ -213,10 +214,10 @@ def validate_config(
|
|||||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||||
if key == "*":
|
if key == "*":
|
||||||
raise ValueError(
|
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):
|
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):
|
if not base or not hostutil.is_subdomain(base, rp_id):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Origin '{key}' is a wildcard outside the rp-id "
|
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
|
props = True # canonicalize junk/empty entries to presence-only
|
||||||
if key == "*":
|
if key == "*":
|
||||||
warn(
|
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 "")
|
+ (" — auth host mark cleared" if is_auth else "")
|
||||||
)
|
)
|
||||||
origins[f"*.{rp_id}"] = True
|
origins[f"**.{rp_id}"] = True
|
||||||
continue
|
continue
|
||||||
if hostutil.is_wildcard_pattern(key):
|
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):
|
if not base or not hostutil.is_subdomain(base, rp_id):
|
||||||
warn(
|
warn(
|
||||||
f"Domain '{rp_id}': origin '{key}' is a wildcard "
|
f"Domain '{rp_id}': origin '{key}' is a wildcard "
|
||||||
|
|||||||
+27
-13
@@ -56,12 +56,15 @@ class Passkey:
|
|||||||
rp_id: Your security domain (e.g. "example.com")
|
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.
|
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
|
origins: Allow-list of sign-in site origins within the rp-id domain
|
||||||
(e.g. ["https://app.example.com"]); wildcard patterns like
|
(e.g. ["https://app.example.com"]); wildcard patterns
|
||||||
"*.example.com" match the base domain and its subdomains
|
follow the shell-glob convention: "**.example.com" matches
|
||||||
over https only — except under localhost ("*.localhost"),
|
the base domain and its subdomains at any depth, while
|
||||||
which matches any scheme and any port. Exact entries match
|
"*.example.com" matches exactly one subdomain level —
|
||||||
scheme, host and port. An empty list (the default) allows
|
over https only, except under localhost
|
||||||
nothing — pass ["*.{rp-id}"] to allow the whole domain.
|
("**.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
|
related_origins: Origins on unrelated domains that may assert this
|
||||||
rp-id (WebAuthn Related Origin Requests). Always additive.
|
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).
|
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:
|
def _allowlisted(self, origin: str) -> bool:
|
||||||
"""Check an in-domain origin against the allow-list.
|
"""Check an in-domain origin against the allow-list.
|
||||||
|
|
||||||
An entry matches exactly; a wildcard pattern ('*.example.com')
|
An entry matches exactly. A wildcard pattern matches hostnames
|
||||||
matches the base domain and any subdomain of it over https only,
|
under its base: '**.example.com' covers the base domain itself and
|
||||||
except under localhost ('*.localhost'), which matches any scheme
|
subdomains at any depth, while '*.example.com' covers exactly one
|
||||||
and any port.
|
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:
|
if origin in self.allowed_origins:
|
||||||
return True
|
return True
|
||||||
hostname = hostutil.origin_hostname(origin)
|
hostname = hostutil.origin_hostname(origin)
|
||||||
for entry in self.allowed_origins:
|
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
|
continue
|
||||||
base = entry[2:].rstrip(".")
|
if entry.startswith("**."):
|
||||||
if not hostutil.is_subdomain(hostname, base):
|
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
|
continue
|
||||||
if hostutil.is_subdomain(base, "localhost"):
|
if hostutil.is_subdomain(base, "localhost"):
|
||||||
return True # localhost: any scheme, any port
|
return True # localhost: any scheme, any port
|
||||||
|
|||||||
+17
-6
@@ -19,15 +19,26 @@ def validate_rp_id(rp_id: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def is_wildcard_pattern(value: str) -> bool:
|
def is_wildcard_pattern(value: str) -> bool:
|
||||||
"""Check whether an origins entry is a wildcard pattern like '*.example.com'."""
|
"""Check whether an origins entry is a wildcard pattern like
|
||||||
return value.startswith("*.")
|
'*.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:
|
def normalize_origin(origin: str) -> str:
|
||||||
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.
|
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.
|
||||||
|
|
||||||
Wildcard patterns ('*.example.com') pass through unchanged — they are
|
Wildcard patterns ('*.example.com', '**.example.com') pass through
|
||||||
allow-list entries, not concrete origins.
|
unchanged — they are allow-list entries, not concrete origins.
|
||||||
"""
|
"""
|
||||||
if is_wildcard_pattern(origin):
|
if is_wildcard_pattern(origin):
|
||||||
return origin.rstrip("/.")
|
return origin.rstrip("/.")
|
||||||
@@ -41,8 +52,8 @@ def origin_hostname(origin: str) -> str | None:
|
|||||||
|
|
||||||
For wildcard patterns the base domain is returned.
|
For wildcard patterns the base domain is returned.
|
||||||
"""
|
"""
|
||||||
if is_wildcard_pattern(origin):
|
if base := wildcard_base(origin):
|
||||||
return origin[2:].rstrip(".").lower() or None
|
return base.lower()
|
||||||
return urlparse(origin).hostname
|
return urlparse(origin).hostname
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -96,7 +96,7 @@ async def test_db() -> AsyncGenerator[DB]:
|
|||||||
admin_name="Test Admin",
|
admin_name="Test Admin",
|
||||||
config=Config(
|
config=Config(
|
||||||
domains={
|
domains={
|
||||||
TEST_RP_ID: DomainConfig(origins={f"*.{TEST_RP_ID}": True})
|
TEST_RP_ID: DomainConfig(origins={f"**.{TEST_RP_ID}": True})
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
+2
-2
@@ -1828,7 +1828,7 @@ class TestDomains:
|
|||||||
assert len(data) == 1
|
assert len(data) == 1
|
||||||
domain = data[0]
|
domain = data[0]
|
||||||
assert domain["rp_id"] == "localhost"
|
assert domain["rp_id"] == "localhost"
|
||||||
assert domain["origins"] == {"*.localhost": True}
|
assert domain["origins"] == {"**.localhost": True}
|
||||||
assert "related" not in domain
|
assert "related" not in domain
|
||||||
assert domain["auth_host"] is None
|
assert domain["auth_host"] is None
|
||||||
assert domain["site_url"] == "http://localhost:4401"
|
assert domain["site_url"] == "http://localhost:4401"
|
||||||
@@ -2022,7 +2022,7 @@ class TestDomains:
|
|||||||
)
|
)
|
||||||
assert r.status_code == 200
|
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(
|
r = await client.post(
|
||||||
"/auth/api/admin/domains/",
|
"/auth/api/admin/domains/",
|
||||||
json={"rp_id": "star.com", "origins": {"*": True}},
|
json={"rp_id": "star.com", "origins": {"*": True}},
|
||||||
|
|||||||
+2
-2
@@ -87,7 +87,7 @@ def test_init_defaults(run_cli, tmp_path):
|
|||||||
config = stored_config(tmp_path)
|
config = stored_config(tmp_path)
|
||||||
assert list(config.domains) == ["localhost"]
|
assert list(config.domains) == ["localhost"]
|
||||||
assert config.domains["localhost"].rp_name is None
|
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
|
assert config.listen is None
|
||||||
|
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ def test_init_full_options(run_cli, tmp_path):
|
|||||||
config = stored_config(tmp_path)
|
config = stored_config(tmp_path)
|
||||||
domain = config.domains["example.com"]
|
domain = config.domains["example.com"]
|
||||||
assert domain.rp_name == "Example Corp"
|
assert domain.rp_name == "Example Corp"
|
||||||
assert domain.origins == {"*.example.com": True}
|
assert domain.origins == {"**.example.com": True}
|
||||||
assert config.listen == ["4402"]
|
assert config.listen == ["4402"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+36
-17
@@ -59,7 +59,7 @@ ROR_CONFIG = Config(
|
|||||||
"app.com": True, # related origin (outside the rp-id domain)
|
"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):
|
def test_wildcard_outside_rp_id_rejected(self):
|
||||||
"""Related origins are individual hosts; wildcards must stay within
|
"""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(
|
domains.validate_config(
|
||||||
Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})})
|
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"):
|
with pytest.raises(ValueError, match="wildcard outside the rp-id"):
|
||||||
domains.validate_config(
|
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):
|
def test_wildcard_auth_host_rejected(self):
|
||||||
@@ -365,13 +368,13 @@ class TestSanitizeConfig:
|
|||||||
domains.validate_config(config) # sanitized config is strict-clean
|
domains.validate_config(config) # sanitized config is strict-clean
|
||||||
|
|
||||||
def test_star_origin_rewritten_explicit(self):
|
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."""
|
mark on it is cleared."""
|
||||||
config, warnings = domains.sanitize_config(
|
config, warnings = domains.sanitize_config(
|
||||||
Config(domains={"a.com": DomainConfig(origins={"*": True})})
|
Config(domains={"a.com": DomainConfig(origins={"*": True})})
|
||||||
)
|
)
|
||||||
assert config.domains["a.com"].origins == {"*.a.com": True}
|
assert config.domains["a.com"].origins == {"**.a.com": True}
|
||||||
assert any("'*.'" in w or "*." in w for w in warnings)
|
assert any("**." in w for w in warnings)
|
||||||
domains.validate_config(config)
|
domains.validate_config(config)
|
||||||
|
|
||||||
config, warnings = domains.sanitize_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)
|
assert any("mark cleared" in w for w in warnings)
|
||||||
domains.validate_config(config)
|
domains.validate_config(config)
|
||||||
|
|
||||||
@@ -521,16 +524,31 @@ class TestOriginValidation:
|
|||||||
with pytest.raises(ValueError, match="not allowed"):
|
with pytest.raises(ValueError, match="not allowed"):
|
||||||
p.validate_origin("https://other.com")
|
p.validate_origin("https://other.com")
|
||||||
|
|
||||||
def test_wildcard_entry_matches_subtree(self):
|
def test_double_star_matches_apex_and_any_depth(self):
|
||||||
p = Passkey(rp_id="example.com", origins=["*.example.com"])
|
"""'**.example.com' covers the apex and subdomains at any depth
|
||||||
assert p.validate_origin("https://example.com")
|
(the shell-glob convention)."""
|
||||||
assert p.validate_origin("https://app.example.com")
|
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"):
|
with pytest.raises(ValueError, match="not allowed"):
|
||||||
p.validate_origin("https://other.com")
|
p.validate_origin("https://other.com")
|
||||||
|
|
||||||
def test_wildcard_is_https_only(self):
|
def test_single_star_matches_exactly_one_level(self):
|
||||||
"""A '*.example.com' entry does not fall back to other schemes."""
|
"""'*.example.com' covers exactly one subdomain level — neither the
|
||||||
|
apex nor deeper levels."""
|
||||||
p = Passkey(rp_id="example.com", origins=["*.example.com"])
|
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"):
|
with pytest.raises(ValueError, match="not allowed"):
|
||||||
p.validate_origin("http://example.com")
|
p.validate_origin("http://example.com")
|
||||||
with pytest.raises(ValueError, match="not allowed"):
|
with pytest.raises(ValueError, match="not allowed"):
|
||||||
@@ -542,9 +560,10 @@ class TestOriginValidation:
|
|||||||
|
|
||||||
def test_localhost_wildcard_matches_any_scheme_and_port(self):
|
def test_localhost_wildcard_matches_any_scheme_and_port(self):
|
||||||
"""Under localhost, wildcards match any scheme and any port."""
|
"""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://localhost:8080")
|
||||||
assert p.validate_origin("http://app.localhost:3000")
|
assert p.validate_origin("http://app.localhost:3000")
|
||||||
|
assert p.validate_origin("http://a.b.localhost:3000")
|
||||||
assert p.validate_origin("https://localhost")
|
assert p.validate_origin("https://localhost")
|
||||||
|
|
||||||
def test_exact_entry_matches_scheme_and_port(self):
|
def test_exact_entry_matches_scheme_and_port(self):
|
||||||
@@ -556,7 +575,7 @@ class TestOriginValidation:
|
|||||||
p.validate_origin("http://localhost:4404")
|
p.validate_origin("http://localhost:4404")
|
||||||
|
|
||||||
def test_sub_wildcard_matches_only_its_subtree(self):
|
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://app.example.com")
|
||||||
assert p.validate_origin("https://www.app.example.com")
|
assert p.validate_origin("https://www.app.example.com")
|
||||||
with pytest.raises(ValueError, match="not allowed"):
|
with pytest.raises(ValueError, match="not allowed"):
|
||||||
@@ -870,13 +889,13 @@ class TestLegacyConversion:
|
|||||||
|
|
||||||
def test_convert_empty_origins_seeds_wildcard(self, tmp_path):
|
def test_convert_empty_origins_seeds_wildcard(self, tmp_path):
|
||||||
"""Legacy 'no origins' meant the whole rp-id domain; the new format
|
"""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"
|
src_file = tmp_path / "main.db"
|
||||||
asyncio.run(
|
asyncio.run(
|
||||||
_write_legacy(src_file, LegacyConfig(rp_id="example.com", rp_name="Ex"))
|
_write_legacy(src_file, LegacyConfig(rp_id="example.com", rp_name="Ex"))
|
||||||
)
|
)
|
||||||
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
|
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}
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user