From a6138d97f96b39ae052496f064d775627f5e76b4 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 14:40:37 +0000 Subject: [PATCH] Single origins table per domain; explicit origins semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DomainConfig.related is gone: origins holds both in-domain sign-in sites and related origins, classified by whether the entry lies within the rp-id. Misfiling is impossible by construction, so validation/sanitize lose their reclassification paths. Origins are now always explicit: an empty table allows nothing (a related-only domain is a valid configuration). Plain '*' is rejected — wildcards must be under the rp-id ('*.{rp-id}'). New databases, added domains and legacy conversions seed '*.{rp-id}' (legacy empty origins meant allow-all). Passkey's implicit allow-all default is gone; the admin API takes a single origins map and the lockout guard refuses emptying the table on the domain in use. --- paskia/__main__.py | 17 ++- paskia/db/legacy.py | 4 + paskia/db/operations.py | 4 +- paskia/db/structs.py | 29 ++-- paskia/domains.py | 236 +++++++++++++++----------------- paskia/fastapi/admin/domains.py | 39 ++---- paskia/sansio.py | 57 ++++---- paskia/util/apistructs.py | 8 +- paskia/util/startupbox.py | 9 +- tests/conftest.py | 6 +- tests/test_admin.py | 66 ++++++--- tests/test_cli.py | 4 +- tests/test_domains.py | 212 +++++++++++++++++----------- 13 files changed, 364 insertions(+), 327 deletions(-) diff --git a/paskia/__main__.py b/paskia/__main__.py index 3314fa2..4c2c97e 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) + new = DomainConfig(rp_name=rp_name, origins={f"*.{rp_id}": True}) try: validate_config( Config( @@ -133,11 +133,16 @@ def cmd_init(args: argparse.Namespace) -> None: "convert, not 'paskia init'." ) - # Only rp-id and rp-name are bootstrap-time configuration; everything - # else (origins, 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)}, listen=listen) + # Only rp-id and rp-name are bootstrap-time configuration; the new + # 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})}, + listen=listen, + ) try: validate_config(config) except ValueError as e: diff --git a/paskia/db/legacy.py b/paskia/db/legacy.py index e9c8089..b2e4cb8 100644 --- a/paskia/db/legacy.py +++ b/paskia/db/legacy.py @@ -120,6 +120,10 @@ def convert_legacy_database(src: Path, dst: Path) -> Config: origins[origin_key(origin)] = True if old.config.auth_host: origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True) + 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 new_config = Config( domains={rp_id: DomainConfig(rp_name=old.config.rp_name, origins=origins)}, diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 976b2f2..7647dc2 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -714,10 +714,9 @@ def update_domain( *, rp_name: str | None, origins: dict[str, bool | OriginEntry], - related: dict[str, bool], ctx: SessionContext | None = None, ) -> None: - """Replace a domain's rp_name, origins and related origins (wholesale). + """Replace a domain's rp_name and origins table (wholesale). The rp-id itself is immutable: credentials are stamped with it, so changing it would orphan them — delete and recreate the domain instead. @@ -729,7 +728,6 @@ def update_domain( with _transaction("admin:update_domain", ctx): domain.rp_name = rp_name domain.origins = origins - domain.related = related def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None: diff --git a/paskia/db/structs.py b/paskia/db/structs.py index 254f1f7..9021ecc 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -625,24 +625,21 @@ class OriginEntry(msgspec.Struct, omit_defaults=True): class DomainConfig(msgspec.Struct, omit_defaults=True): """Configuration for one domain (one WebAuthn rp-id). - ``origins`` maps sign-in sites within the rp-id domain to their - properties. Keys are hosts without the https:// scheme - ("app.example.com"), wildcard patterns ("*.example.com" — the base - domain and its subdomains over https only, any scheme and port under - localhost), the bare "*" (shorthand for a wildcard over the rp-id - itself), or full origins when not https ("http://localhost:8080"). - An empty dict means the rp-id and all its subdomains may sign in (the - default). Ordering carries no meaning — display order is decided by - the UI. - - ``related`` lists other domain names that may assert this rp-id - (WebAuthn Related Origin Requests): individual hosts or full origins - only (no wildcards, no "*"). + ``origins`` is a single table of sites that may sign in with this + domain's passkeys, classified by the rp-id: entries within the rp-id + 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 + meaning — display order is decided by the UI. """ rp_name: str | None = None origins: dict[str, bool | OriginEntry] = {} - related: dict[str, bool] = {} class Config(msgspec.Struct, omit_defaults=True): @@ -653,7 +650,9 @@ class Config(msgspec.Struct, omit_defaults=True): """ domains: dict[str, DomainConfig] = msgspec.field( - default_factory=lambda: {"localhost": DomainConfig()} + default_factory=lambda: { + "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 964a414..6e6a498 100644 --- a/paskia/domains.py +++ b/paskia/domains.py @@ -6,6 +6,10 @@ rebuilt on admin domain changes; request dispatch resolves hosts to domains through it. The database itself is global — only the *current domain* (passkey, site URLs) varies per request, tracked via a contextvar set by the dispatch middleware. + +Each domain's ``origins`` table holds both in-domain sign-in sites and +related origins (ROR), classified by the rp-id: entries within the rp-id +domain are in-domain, entries outside it are related. """ from __future__ import annotations @@ -29,15 +33,15 @@ DEFAULT_RELATED_ORIGIN_CAP = 5 def origin_url(key: str) -> str: - """URL form of an origins-dict key (https:// is implied); wildcards and - '*' pass through unchanged.""" - if hostutil.is_wildcard_pattern(key) or key == "*" or "://" in key: + """URL form of an origins-table key (https:// is implied); wildcards + pass through unchanged.""" + if hostutil.is_wildcard_pattern(key) or "://" in key: return key return f"https://{key}" def origin_key(origin: str) -> str: - """Origins-dict key for a full origin URL (https:// omitted). + """Origins-table key for a full origin URL (https:// omitted). Keys are canonicalized: lowercased, and bare hosts/wildcards lose any trailing dot. @@ -50,6 +54,24 @@ def origin_key(origin: str) -> str: return key.lower() +def is_related_key(rp_id: str, key: str) -> bool: + """Whether an origins-table key lies outside the rp-id domain (a + related origin). Wildcards are never related.""" + if hostutil.is_wildcard_pattern(key): + return False + hn = hostutil.origin_hostname(origin_url(key)) + return bool(hn) and not hostutil.is_subdomain(hn, rp_id) + + +def partition_origins( + rp_id: str, origins: dict[str, bool | OriginEntry] +) -> tuple[list[str], list[str]]: + """Split an origins table into (in-domain keys, related keys).""" + in_domain = [k for k in origins if not is_related_key(rp_id, k)] + related = [k for k in origins if is_related_key(rp_id, k)] + return in_domain, related + + def auth_host_url(domain: DomainConfig) -> str | None: """Full URL of the domain's auth host origin, if one is marked.""" for key, props in domain.origins.items(): @@ -62,6 +84,7 @@ class Domain: """Runtime view of one domain: stored config plus derived values.""" def __init__(self, rp_id: str, config: DomainConfig, site_url: str, site_path: str): + in_domain, related = partition_origins(rp_id, config.origins) self.rp_id = rp_id self.config = config self.site_url = site_url @@ -69,8 +92,8 @@ class Domain: self.passkey = Passkey( rp_id=rp_id, rp_name=config.rp_name, - origins=[origin_url(k) for k in config.origins] or None, - related_origins=[origin_url(k) for k in config.related], + origins=[origin_url(k) for k in in_domain], + related_origins=[origin_url(k) for k in related], ) @property @@ -85,8 +108,8 @@ class Domain: @property def related_origins(self) -> list[str]: - """Configured related (cross-domain) origins for ROR, as URLs.""" - return [origin_url(k) for k in self.config.related] + """Related (cross-domain) origins for ROR, as URLs.""" + return sorted(self.passkey.related_origins) @property def ui_base_path(self) -> str: @@ -185,19 +208,20 @@ def validate_config( hostutil.validate_rp_id(rp_id) domain_auth_host: str | None = None + related_count = 0 for key, props in domain.origins.items(): is_auth = isinstance(props, OriginEntry) and props.auth_host if key == "*": - # Shorthand for '*.{rp_id}' - if is_auth: - raise ValueError("Origin '*' cannot be the auth host") - continue + raise ValueError( + f"Origin '*' is not allowed — list '*.{rp_id}' explicitly" + ) if hostutil.is_wildcard_pattern(key): base = key[2:].rstrip(".") if not base or not hostutil.is_subdomain(base, rp_id): raise ValueError( - f"Origin '{key}' is outside the rp-id domain " - f"'{rp_id}' — configure it as a related origin instead" + f"Origin '{key}' is a wildcard outside the rp-id " + f"domain '{rp_id}' — related origins must be " + "individual hosts" ) if is_auth: raise ValueError(f"Wildcard origin '{key}' cannot be the auth host") @@ -205,49 +229,28 @@ def validate_config( hn = hostutil.origin_hostname(origin_url(key)) if not hn: raise ValueError(f"Invalid origin: '{key}'") - if not hostutil.is_subdomain(hn, rp_id): - raise ValueError( - f"Origin '{key}' is outside the rp-id domain " - f"'{rp_id}' — configure it as a related origin instead" - ) - if is_auth: - if domain_auth_host is not None: - raise ValueError( - f"Domain '{rp_id}' marks several origins as the auth " - f"host ('{domain_auth_host}' and '{key}') — only one allowed" - ) - domain_auth_host = key - ah = hostutil.normalize_host( - hostutil.auth_host_netloc(origin_url(key)) or "" - ) - # Several domains may share an auth host to consolidate - # logins; resolution picks the best suffix match. - auth_hosts.setdefault(ah, rp_id) - - if len(domain.related) > related_origin_cap: - raise ValueError( - f"Domain '{rp_id}' has {len(domain.related)} " - f"related origins (maximum {related_origin_cap})" - ) - for key in domain.related: - if key == "*": - raise ValueError( - "Related origin '*' is not allowed — related origins " - "(ROR) must be listed individually" - ) - if hostutil.is_wildcard_pattern(key): - raise ValueError( - f"Related origin '{key}' is a wildcard — related " - "origins (ROR) must be listed individually" - ) - hn = hostutil.origin_hostname(origin_url(key)) - if not hn: - raise ValueError(f"Invalid related origin: '{key}'") if hostutil.is_subdomain(hn, rp_id): + if is_auth: + if domain_auth_host is not None: + raise ValueError( + f"Domain '{rp_id}' marks several origins as the auth " + f"host ('{domain_auth_host}' and '{key}') — only one allowed" + ) + domain_auth_host = key + ah = hostutil.normalize_host( + hostutil.auth_host_netloc(origin_url(key)) or "" + ) + # Several domains may share an auth host to consolidate + # logins; resolution picks the best suffix match. + auth_hosts.setdefault(ah, rp_id) + continue + # Related origin (outside the rp-id domain) + if is_auth: raise ValueError( - f"Related origin '{key}' is within the rp-id domain " - f"'{rp_id}' — subdomains need no related origin entry" + f"Related origin '{key}' cannot be the auth host — the " + "auth host must be within the rp-id domain" ) + related_count += 1 # A related host may be (or fall inside) another domain's # rp-id: a host that *is* a configured rp-id always serves its # own domain; otherwise the related listing wins dispatch over @@ -262,6 +265,11 @@ def validate_config( f"'{related_hosts[hn]}' and '{rp_id}'" ) related_hosts[hn] = rp_id + if related_count > related_origin_cap: + raise ValueError( + f"Domain '{rp_id}' has {related_count} related origins " + f"(maximum {related_origin_cap})" + ) rp_ids = set(config.domains) for hn, owner in auth_hosts.items(): @@ -299,31 +307,25 @@ def sanitize_config( continue origins: dict[str, bool | OriginEntry] = {} - related: dict[str, bool] = dict(domain.related) auth_seen = False for key, props in domain.origins.items(): is_auth = isinstance(props, OriginEntry) and props.auth_host if not is_auth: props = True # canonicalize junk/empty entries to presence-only if key == "*": - # Shorthand for '*.{rp_id}'; wildcards cannot be auth hosts - if is_auth: - warn( - f"Domain '{rp_id}': origin '*' cannot be the " - "auth host — mark cleared" - ) + warn( + f"Domain '{rp_id}': origin '*' rewritten as '*.{rp_id}'" + + (" — auth host mark cleared" if is_auth else "") + ) origins[f"*.{rp_id}"] = True continue if hostutil.is_wildcard_pattern(key): base = key[2:].rstrip(".") - if not base: - warn(f"Domain '{rp_id}': invalid origin '{key}' dropped") - continue - if not hostutil.is_subdomain(base, rp_id): + if not base or not hostutil.is_subdomain(base, rp_id): warn( - f"Domain '{rp_id}': origin '{key}' is outside the " - "rp-id domain — dropped (wildcards cannot be " - "related origins)" + f"Domain '{rp_id}': origin '{key}' is a wildcard " + "outside the rp-id domain — dropped (related origins " + "must be individual hosts)" ) continue if is_auth: @@ -338,60 +340,33 @@ def sanitize_config( if not hn: warn(f"Domain '{rp_id}': invalid origin '{key}' dropped") continue - if hostutil.is_subdomain(hn, rp_id): - if is_auth: - if auth_seen: - warn( - f"Domain '{rp_id}': several origins marked as " - f"auth host — extra mark on '{key}' cleared" - ) - props = True - else: - auth_seen = True - origins[key] = props - else: - warn( - f"Domain '{rp_id}': origin '{key}' is outside the rp-id " - "domain — treating it as a related origin; fix the " - "lists in the admin interface" - ) - related[origin_key(origin_url(key))] = True + if is_auth: + if not hostutil.is_subdomain(hn, rp_id): + warn( + f"Domain '{rp_id}': related origin '{key}' cannot be " + "the auth host — mark cleared" + ) + props = True + elif auth_seen: + warn( + f"Domain '{rp_id}': several origins marked as " + f"auth host — extra mark on '{key}' cleared" + ) + props = True + else: + auth_seen = True + origins[key] = props - related_ok: dict[str, bool] = {} - for key in related: - if key == "*": - warn( - f"Domain '{rp_id}': related origin '*' is not allowed — " - "dropped (ROR entries must be individual)" - ) - continue - if hostutil.is_wildcard_pattern(key): - warn( - f"Domain '{rp_id}': related origin '{key}' is a " - "wildcard — dropped (ROR entries must be individual)" - ) - continue - hn = hostutil.origin_hostname(origin_url(key)) - if not hn: - warn(f"Domain '{rp_id}': invalid related origin '{key}' dropped") - continue - if hostutil.is_subdomain(hn, rp_id): - warn( - f"Domain '{rp_id}': related origin '{key}' is within the " - "rp-id domain — dropped (subdomains need no related entry)" - ) - continue - related_ok[key] = True - if len(related_ok) > related_origin_cap: + related = sorted(k for k in origins if is_related_key(rp_id, k)) + if len(related) > related_origin_cap: warn( - f"Domain '{rp_id}': {len(related_ok)} related origins exceed " + f"Domain '{rp_id}': {len(related)} related origins exceed " f"the maximum of {related_origin_cap} — extras dropped" ) - related_ok = dict(sorted(related_ok.items())[:related_origin_cap]) + for key in related[related_origin_cap:]: + del origins[key] - domains[rp_id] = DomainConfig( - rp_name=domain.rp_name, origins=origins, related=related_ok - ) + domains[rp_id] = DomainConfig(rp_name=domain.rp_name, origins=origins) if not domains: raise ValueError("No servable domain in the stored configuration") @@ -423,26 +398,29 @@ def sanitize_config( seen_related: dict[str, str] = {} for rp_id, domain in domains.items(): - keep: dict[str, bool] = {} - for key in domain.related: + drop = [] + for key in domain.origins: + if not is_related_key(rp_id, key): + continue hn = hostutil.origin_hostname(origin_url(key)) - covered_by_rp_id = any(hostutil.is_subdomain(hn, o) for o in rp_ids) - if covered_by_rp_id: - keep[key] = True - elif hn in seen_auth_hosts: + if any(hostutil.is_subdomain(hn, o) for o in rp_ids): + continue # covered by a configured rp-id + if hn in seen_auth_hosts: warn( f"Domain '{rp_id}': related origin '{key}' is the " f"auth host of '{seen_auth_hosts[hn]}' — dropped" ) + drop.append(key) elif hn in seen_related: warn( f"Domain '{rp_id}': related origin '{key}' is also " f"used by '{seen_related[hn]}' — dropped (first domain wins)" ) + drop.append(key) else: seen_related[hn] = rp_id - keep[key] = True - domain.related = keep + for key in drop: + del domain.origins[key] return Config(domains=domains, listen=config.listen), warnings @@ -452,8 +430,8 @@ def _derive_site( ) -> tuple[str, str]: """Compute a domain's site_url and site_path. - Priority: auth host > exact rp-id origin key > first concrete origin - key (sorted) > PASKIA_VITE_URL (localhost domain only) > + Priority: auth host > exact rp-id origin key > first concrete in-domain + origin key (sorted) > PASKIA_VITE_URL (localhost domain only) > http://localhost:port (localhost domain) > https://rp-id. """ if auth := auth_host_url(domain): @@ -461,7 +439,9 @@ def _derive_site( if rp_id in domain.origins: return origin_url(rp_id), "/auth/" concrete = sorted( - k for k in domain.origins if k != "*" and not hostutil.is_wildcard_pattern(k) + k + for k in domain.origins + if not hostutil.is_wildcard_pattern(k) and not is_related_key(rp_id, k) ) if concrete: return origin_url(concrete[0]), "/auth/" diff --git a/paskia/fastapi/admin/domains.py b/paskia/fastapi/admin/domains.py index 1354d7e..94b5fa6 100644 --- a/paskia/fastapi/admin/domains.py +++ b/paskia/fastapi/admin/domains.py @@ -1,10 +1,11 @@ """Domain (rp-id) management API — master admin only. -Each domain is one rp-id with its own rp-name, allowed in-domain sign-in -sites (origins, one of which may be marked as the auth host), and optional -related origins on unrelated domains (WebAuthn Related Origin Requests). -All changes are validated cross-domain before being persisted, and the -runtime domain registry is rebuilt after each change so it takes effect +Each domain is one rp-id with its own rp-name and an origins table of +sign-in sites: entries within the rp-id domain are in-domain sites (one of +which may be marked as the auth host), entries outside it are related +origins on unrelated domains (WebAuthn Related Origin Requests). All +changes are validated cross-domain before being persisted, and the runtime +domain registry is rebuilt after each change so it takes effect immediately. """ @@ -30,7 +31,6 @@ def _domain_to_api(domain: domains.Domain) -> ApiDomain: rp_id=domain.rp_id, rp_name=domain.rp_name, origins=domain.config.origins, - related=domain.config.related, site_url=domain.site_url, auth_site_url=domain.auth_site_url, auth_host=domain.own_auth_host, @@ -41,7 +41,8 @@ def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry] """Normalize an origins object from the admin UI (raises on malformed). Keys arrive as bare hosts, wildcard patterns, or full origins; they are - stored as origins-dict keys (https:// omitted). + stored as origins-table keys (https:// omitted). In-domain vs. related + classification is derived from the rp-id at validation time. """ out: dict[str, bool | OriginEntry] = {} for raw_key, raw_props in (values or {}).items(): @@ -55,22 +56,6 @@ def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry] return out -def _normalize_related_map(values: dict | None) -> dict[str, bool]: - """Normalize a related-origins object from the admin UI.""" - out: dict[str, bool] = {} - for raw_key in values or {}: - key = raw_key.strip() - if not key: - continue - if key == "*" or hostutil.is_wildcard_pattern(key): - raise ValueError( - f"Related origin '{key}' is a wildcard — related origins " - "(ROR) must be listed individually" - ) - out[domains.origin_key(hostutil.normalize_origin(key))] = True - return out - - def _rebuild_registry() -> None: """Rebuild the runtime domain registry from the stored configuration.""" domains.init_registry(db.data().config) @@ -94,10 +79,11 @@ def _check_not_locking_self_out( raw_host = (request.headers.get("host") or "").rstrip(".") if not raw_host: return + in_domain, related = domains.partition_origins(rp_id, domain.origins) probe = Passkey( rp_id=rp_id, - origins=[domains.origin_url(k) for k in domain.origins] or None, - related_origins=[domains.origin_url(k) for k in domain.related], + origins=[domains.origin_url(k) for k in in_domain], + related_origins=[domains.origin_url(k) for k in related], ) for scheme in ("https", "http"): try: @@ -137,7 +123,6 @@ async def admin_create_domain( new = DomainConfig( rp_name=(payload.get("rp_name") or "").strip() or None, origins=_normalize_origins_map(payload.get("origins")), - related=_normalize_related_map(payload.get("related")), ) config = db.data().config @@ -174,7 +159,6 @@ async def admin_update_domain( updated = DomainConfig( rp_name=(payload.get("rp_name") or "").strip() or None, origins=_normalize_origins_map(payload.get("origins")), - related=_normalize_related_map(payload.get("related")), ) would_be = Config( domains={k: updated if k == rp_id else v for k, v in config.domains.items()}, @@ -187,7 +171,6 @@ async def admin_update_domain( rp_id, rp_name=updated.rp_name, origins=updated.origins, - related=updated.related, ctx=ctx, ) _rebuild_registry() diff --git a/paskia/sansio.py b/paskia/sansio.py index d84024e..0490687 100644 --- a/paskia/sansio.py +++ b/paskia/sansio.py @@ -56,14 +56,12 @@ 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"]); "*" is shorthand for - a wildcard over the whole rp-id domain, and 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. If not - provided, the rp-id and any subdomain of it may - authenticate (same as listing "*"). + (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. 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). @@ -76,25 +74,19 @@ class Passkey: self.rp_id = rp_id hostutil.validate_rp_id(rp_id) self.rp_name = rp_name or rp_id - self.allowed_origins: set[str] | None = None - if origins: - # Validate and deduplicate origins into a set for O(1) lookups - normalized = [] - for o in origins: - if o == "*": - o = f"*.{rp_id}" # shorthand: the whole rp-id domain - if hostutil.is_wildcard_pattern(o): - hostname = hostutil.origin_hostname(o) - else: - self._validate_origin_url(o) - hostname = hostutil.origin_hostname(o) - if not hostname or not hostutil.is_subdomain(hostname, rp_id): - raise ValueError( - f"Origin '{o}' is outside the rp-id domain '{rp_id}' — " - "configure it as a related origin instead" - ) - normalized.append(o) - self.allowed_origins = set(normalized) + self.allowed_origins: set[str] = set() + for o in origins or []: + if hostutil.is_wildcard_pattern(o): + hostname = hostutil.origin_hostname(o) + else: + self._validate_origin_url(o) + hostname = hostutil.origin_hostname(o) + if not hostname or not hostutil.is_subdomain(hostname, rp_id): + raise ValueError( + f"Origin '{o}' is outside the rp-id domain '{rp_id}' — " + "pass it as a related origin instead" + ) + self.allowed_origins.add(o) self.related_origins: set[str] = set() for o in related_origins or []: if hostutil.is_wildcard_pattern(o): @@ -153,11 +145,10 @@ class Passkey: def validate_origin(self, origin: str) -> str: """Validate that origin is allowed and return it. - An in-domain origin (rp-id or subdomain) is valid unless an - allow-list of origins is configured, in which case it must match - a listed origin or wildcard pattern. An origin outside the rp-id - domain is valid only when explicitly listed as a related origin - (Related Origin Requests). + An in-domain origin (rp-id or subdomain) must match a listed origin + or wildcard pattern. An origin outside the rp-id domain is valid + only when explicitly listed as a related origin (Related Origin + Requests). Args: origin: The origin URL to validate (from WebSocket request header) @@ -170,7 +161,7 @@ class Passkey: """ self._validate_origin_url(origin) if self._origin_in_subtree(origin): - if self.allowed_origins is None or self._allowlisted(origin): + if self._allowlisted(origin): return origin elif origin in self.related_origins: return origin diff --git a/paskia/util/apistructs.py b/paskia/util/apistructs.py index a10012d..6e5eecc 100644 --- a/paskia/util/apistructs.py +++ b/paskia/util/apistructs.py @@ -182,15 +182,15 @@ class ApiSettings(msgspec.Struct): class ApiDomain(msgspec.Struct): """Domain entry in the admin domain list response. - origins/related mirror the stored configuration: objects keyed by host - or wildcard pattern (https:// omitted), values True or an object with - extra properties (auth_host). + origins mirrors the stored configuration: an object keyed by host or + wildcard pattern (https:// omitted), values True or an object with + extra properties (auth_host). Entries outside the rp-id domain are + related origins. """ rp_id: str rp_name: str origins: dict[str, bool | OriginEntry] - related: dict[str, bool] site_url: str auth_site_url: str auth_host: str | None diff --git a/paskia/util/startupbox.py b/paskia/util/startupbox.py index 52022c8..208c664 100644 --- a/paskia/util/startupbox.py +++ b/paskia/util/startupbox.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: from paskia.domains import DomainRegistry from paskia.db.structs import OriginEntry -from paskia.domains import origin_url +from paskia.domains import is_related_key, origin_url BOX_WIDTH = 60 # Inner width (excluding box chars) @@ -103,11 +103,10 @@ def print_startup_config( if isinstance(props, OriginEntry) and props.auth_host else "" ) - lines.append(line(f" Origin: {origin_url(key)}{marker}")) + label = "Related:" if is_related_key(domain.rp_id, key) else "Origin:" + lines.append(line(f" {label:<14}{origin_url(key)}{marker}")) if not domain.config.origins: - lines.append(line(f" Origin: {domain.rp_id} and subdomains")) - for key in sorted(domain.config.related): - lines.append(line(f" Related: {origin_url(key)}")) + lines.append(line(" Origins: (none configured)")) lines.append(bottom()) stderr.write("".join(lines)) diff --git a/tests/conftest.py b/tests/conftest.py index 53edf2b..3518120 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -94,7 +94,11 @@ async def test_db() -> AsyncGenerator[DB]: data, org_name="Test Organization", admin_name="Test Admin", - config=Config(domains={TEST_RP_ID: DomainConfig()}), + config=Config( + domains={ + TEST_RP_ID: DomainConfig(origins={f"*.{TEST_RP_ID}": True}) + } + ), ) await kanta.open() diff --git a/tests/test_admin.py b/tests/test_admin.py index e371485..f7b1e6c 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1828,8 +1828,8 @@ class TestDomains: assert len(data) == 1 domain = data[0] assert domain["rp_id"] == "localhost" - assert domain["origins"] == {} - assert domain["related"] == {} + assert domain["origins"] == {"*.localhost": True} + assert "related" not in domain assert domain["auth_host"] is None assert domain["site_url"] == "http://localhost:4401" @@ -1900,26 +1900,39 @@ class TestDomains: self, client: httpx.AsyncClient, session_token: str, - test_user, - test_credential, ): - """With no origins left, site_url must not keep the removed auth host.""" - headers = await self._set_auth_host( - client, session_token, test_user, test_credential + """Emptying a domain's origins table must not keep the removed auth + host in derived URLs. Only possible on a domain other than the one + in use — the lockout guard refuses it there.""" + headers = {**auth_headers(session_token), "Host": "localhost:4401"} + r = await client.post( + "/auth/api/admin/domains/", + json={ + "rp_id": "example.com", + "origins": { + "auth.example.com": {"auth_host": True}, + "app.example.com": True, + }, + }, + headers=headers, ) + assert r.status_code == 200, r.text + domain = domains.registry().get("example.com") + assert domain.own_auth_host == "auth.example.com" + assert "auth.example.com" in domain.site_url r = await client.patch( - "/auth/api/admin/domains/localhost", + "/auth/api/admin/domains/example.com", json={"rp_name": "", "origins": {}}, headers=headers, ) assert r.status_code == 200, r.text - domain = domains.registry().get("localhost") + domain = domains.registry().get("example.com") assert domain.own_auth_host is None assert domain.ui_base_path == "/auth/" - assert "auth.localhost" not in domain.site_url - assert "auth.localhost" not in domain.auth_site_url + assert "auth.example.com" not in domain.site_url + assert "auth.example.com" not in domain.auth_site_url @pytest.mark.asyncio async def test_create_and_delete_domain( @@ -1931,8 +1944,7 @@ class TestDomains: json={ "rp_id": "example.com", "rp_name": "Example", - "origins": {"app.example.com": True}, - "related": {"unrelated-site.com": True}, + "origins": {"app.example.com": True, "unrelated-site.com": True}, }, headers=headers, ) @@ -1943,7 +1955,12 @@ class TestDomains: assert set(domains_list) == {"localhost", "example.com"} created = domains_list["example.com"] assert created["rp_name"] == "Example" - assert created["related"] == {"unrelated-site.com": True} + # In-domain and related origins live in one table; classification + # is derived from the rp-id + assert created["origins"] == { + "app.example.com": True, + "unrelated-site.com": True, + } r = await client.delete("/auth/api/admin/domains/example.com", headers=headers) assert r.status_code == 200, r.text @@ -1986,29 +2003,29 @@ class TestDomains: # Related origin host may not collide across domains r = await client.post( "/auth/api/admin/domains/", - json={"rp_id": "example.com", "related": {"shared-app.com": True}}, + json={"rp_id": "example.com", "origins": {"shared-app.com": True}}, headers=headers, ) assert r.status_code == 200 r = await client.post( "/auth/api/admin/domains/", - json={"rp_id": "other.com", "related": {"shared-app.com": True}}, + json={"rp_id": "other.com", "origins": {"shared-app.com": True}}, headers=headers, ) assert r.status_code == 400 - # Cross-domain entries are rejected from the in-domain origins list + # Cross-domain entries are related origins — accepted in the same table r = await client.post( "/auth/api/admin/domains/", json={"rp_id": "another.com", "origins": {"elsewhere.com": True}}, headers=headers, ) - assert r.status_code == 400 + assert r.status_code == 200 - # In-domain entries are rejected from the related origins list + # Plain '*' is rejected — wildcards must be explicit ('*.another.com') r = await client.post( "/auth/api/admin/domains/", - json={"rp_id": "another.com", "related": {"app.another.com": True}}, + json={"rp_id": "star.com", "origins": {"*": True}}, headers=headers, ) assert r.status_code == 400 @@ -2060,6 +2077,15 @@ class TestDomains: assert r.status_code == 400 assert "lock you out" in r.text + # Emptying the origins table entirely is likewise a lockout + r = await client.patch( + "/auth/api/admin/domains/localhost", + json={"rp_name": "", "origins": {}}, + headers=headers, + ) + assert r.status_code == 400 + assert "lock you out" in r.text + # Allow-list including the current host is fine r = await client.patch( "/auth/api/admin/domains/localhost", diff --git a/tests/test_cli.py b/tests/test_cli.py index 61d9cde..0c99622 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 == {} + 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 == {} + assert domain.origins == {"*.example.com": True} assert config.listen == ["4402"] diff --git a/tests/test_domains.py b/tests/test_domains.py index 11ee934..5889619 100644 --- a/tests/test_domains.py +++ b/tests/test_domains.py @@ -54,10 +54,12 @@ ROR_CONFIG = Config( domains={ "company.com": DomainConfig( rp_name="Company", - origins={"auth.company.com": OriginEntry(auth_host=True)}, - related={"app.com": True}, + origins={ + "auth.company.com": OriginEntry(auth_host=True), + "app.com": True, # related origin (outside the rp-id domain) + }, ), - "pro.com": DomainConfig(rp_name="Pro"), + "pro.com": DomainConfig(rp_name="Pro", origins={"*.pro.com": True}), } ) @@ -183,12 +185,20 @@ class TestValidateConfig: def test_valid(self): domains.validate_config(ROR_CONFIG) + def test_empty_origins_table_is_valid(self): + """No origins at all: nothing of the domain is allowed, but the + configuration itself is legal (e.g. a related-only domain).""" + domains.validate_config(Config(domains={"a.com": DomainConfig()})) + domains.validate_config( + Config(domains={"a.com": DomainConfig(origins={"b.com": True})}) + ) + def test_related_origin_cap(self): domains.validate_config( Config( domains={ "company.com": DomainConfig( - related={f"app{i}.com": True for i in range(5)} + origins={f"app{i}.com": True for i in range(5)} ) } ) @@ -198,38 +208,35 @@ class TestValidateConfig: Config( domains={ "company.com": DomainConfig( - related={f"app{i}.com": True for i in range(6)} + origins={f"app{i}.com": True for i in range(6)} ) } ) ) - def test_origin_outside_rp_id_rejected(self): - """In-domain origins are an allow-list; cross-domain needs related.""" - with pytest.raises(ValueError, match="outside the rp-id domain"): + def test_star_origin_rejected(self): + """Plain '*' suggests 'anything goes' — the wildcard must be + explicit and under the rp-id.""" + with pytest.raises(ValueError, match="not allowed"): domains.validate_config( - Config(domains={"a.com": DomainConfig(origins={"elsewhere.com": True})}) + Config(domains={"a.com": DomainConfig(origins={"*": True})}) ) - def test_related_origin_inside_own_domain_rejected(self): - """Subdomains of the rp-id are covered already; listing is an error.""" - with pytest.raises(ValueError, match="within the rp-id domain"): - domains.validate_config( - Config(domains={"a.com": DomainConfig(related={"app.a.com": 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.""" + config = Config(domains={"a.com": DomainConfig(origins={"app.a.com": True})}) + domains.validate_config(config) + reg = build_registry(config.domains) + assert reg.get("a.com").related_origins == [] - def test_wildcard_related_origin_rejected(self): - """ROR entries are always individual origins; wildcards are meaningless.""" - with pytest.raises(ValueError, match="wildcard"): - domains.validate_config( - Config(domains={"a.com": DomainConfig(related={"*.b.com": True})}) - ) - - def test_wildcard_origin_in_domain_accepted(self): + def test_wildcard_outside_rp_id_rejected(self): + """Related origins are individual hosts; wildcards must stay within + the rp-id domain.""" domains.validate_config( Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})}) ) - with pytest.raises(ValueError, match="outside the rp-id domain"): + with pytest.raises(ValueError, match="wildcard outside the rp-id"): domains.validate_config( Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})}) ) @@ -246,16 +253,30 @@ class TestValidateConfig: ) ) - def test_star_origin_accepted_not_auth_host(self): - domains.validate_config( - Config(domains={"a.com": DomainConfig(origins={"*": True})}) - ) + def test_related_auth_host_rejected(self): + """The auth host is always in-domain; a related origin cannot + carry the mark.""" with pytest.raises(ValueError, match="cannot be the auth host"): domains.validate_config( Config( domains={ "a.com": DomainConfig( - origins={"*": OriginEntry(auth_host=True)} + origins={"auth.b.com": OriginEntry(auth_host=True)} + ) + } + ) + ) + + def test_several_auth_hosts_rejected(self): + with pytest.raises(ValueError, match="several origins as the auth host"): + domains.validate_config( + Config( + domains={ + "a.com": DomainConfig( + origins={ + "auth.a.com": OriginEntry(auth_host=True), + "login.a.com": OriginEntry(auth_host=True), + } ) } ) @@ -269,7 +290,7 @@ class TestValidateConfig: "a.com": DomainConfig( origins={"auth.a.com": OriginEntry(auth_host=True)} ), - "b.com": DomainConfig(related={"auth.a.com": True}), + "b.com": DomainConfig(origins={"auth.a.com": True}), } ) ) @@ -280,7 +301,7 @@ class TestValidateConfig: *is* a configured rp-id always serves its own domain).""" config = Config( domains={ - "a.com": DomainConfig(related={"app.b.com": True}), + "a.com": DomainConfig(origins={"app.b.com": True}), "b.com": DomainConfig(), } ) @@ -295,8 +316,8 @@ class TestValidateConfig: domains.validate_config( Config( domains={ - "a.com": DomainConfig(related={"app.b.com": True}), - "c.com": DomainConfig(related={"app.b.com": True}), + "a.com": DomainConfig(origins={"app.b.com": True}), + "c.com": DomainConfig(origins={"app.b.com": True}), "b.com": DomainConfig(), } ) @@ -305,8 +326,8 @@ class TestValidateConfig: domains.validate_config( Config( domains={ - "a.com": DomainConfig(related={"shared.com": True}), - "c.com": DomainConfig(related={"shared.com": True}), + "a.com": DomainConfig(origins={"shared.com": True}), + "c.com": DomainConfig(origins={"shared.com": True}), } ) ) @@ -333,16 +354,37 @@ class TestValidateConfig: class TestSanitizeConfig: """Serving never fails on stored config problems; it degrades + warns.""" - def test_cross_domain_origin_moved_to_related(self): + def test_cross_domain_origin_stays_as_related(self): + """An out-of-domain entry simply IS a related origin — no repair + needed, no warning.""" config, warnings = domains.sanitize_config( Config(domains={"localhost": DomainConfig(origins={"example.com": True})}) ) - domain = config.domains["localhost"] - assert domain.origins == {} - assert domain.related == {"example.com": True} - assert any("related origin" in w for w in warnings) + assert config.domains["localhost"].origins == {"example.com": True} + assert not warnings 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 + 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) + domains.validate_config(config) + + config, warnings = domains.sanitize_config( + Config( + domains={ + "a.com": DomainConfig(origins={"*": OriginEntry(auth_host=True)}) + } + ) + ) + assert config.domains["a.com"].origins == {"*.a.com": True} + assert any("mark cleared" in w for w in warnings) + domains.validate_config(config) + def test_malformed_origin_dropped(self): config, warnings = domains.sanitize_config( Config(domains={"a.com": DomainConfig(origins={"https://": True})}) @@ -357,17 +399,11 @@ class TestSanitizeConfig: assert list(config.domains) == ["ok.com"] assert any("dropped" in w for w in warnings) - def test_related_inside_own_domain_dropped(self): - config, _ = domains.sanitize_config( - Config(domains={"a.com": DomainConfig(related={"app.a.com": True})}) - ) - assert config.domains["a.com"].related == {} - - def test_wildcard_related_origin_dropped(self): + def test_wildcard_outside_rp_id_dropped(self): config, warnings = domains.sanitize_config( - Config(domains={"a.com": DomainConfig(related={"*.b.com": True})}) + Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})}) ) - assert config.domains["a.com"].related == {} + assert config.domains["a.com"].origins == {} assert any("wildcard" in w for w in warnings) domains.validate_config(config) # sanitized config is strict-clean @@ -376,16 +412,17 @@ class TestSanitizeConfig: Config( domains={ "a.com": DomainConfig( - related={f"app{i}.com": True for i in range(6)} + origins={f"app{i}.com": True for i in range(6)} ) } ) ) - assert len(config.domains["a.com"].related) == 5 + assert len(config.domains["a.com"].origins) == 5 assert any("maximum" in w for w in warnings) + domains.validate_config(config) - def test_auth_host_outside_domain_becomes_related(self): - """An auth-marked origin outside the rp-id degrades to a related origin.""" + def test_related_auth_host_mark_cleared(self): + """An auth mark on a related (out-of-domain) origin is cleared.""" config, warnings = domains.sanitize_config( Config( domains={ @@ -395,10 +432,9 @@ class TestSanitizeConfig: } ) ) - domain = config.domains["a.com"] - assert domain.origins == {} - assert domain.related == {"auth.b.com": True} - assert any("related origin" in w for w in warnings) + assert config.domains["a.com"].origins == {"auth.b.com": True} + assert any("cannot be the auth host" in w for w in warnings) + domains.validate_config(config) def test_auth_host_colliding_with_rp_id_cleared(self): config, warnings = domains.sanitize_config( @@ -419,25 +455,25 @@ class TestSanitizeConfig: config, _ = domains.sanitize_config( Config( domains={ - "a.com": DomainConfig(related={"app.b.com": True}), + "a.com": DomainConfig(origins={"app.b.com": True}), "b.com": DomainConfig(), } ) ) - assert config.domains["a.com"].related == {"app.b.com": True} + assert config.domains["a.com"].origins == {"app.b.com": True} def test_related_claimed_twice_first_wins(self): """Two non-owner domains claiming one related host: first wins.""" config, warnings = domains.sanitize_config( Config( domains={ - "a.com": DomainConfig(related={"shared.com": True}), - "b.com": DomainConfig(related={"shared.com": True}), + "a.com": DomainConfig(origins={"shared.com": True}), + "b.com": DomainConfig(origins={"shared.com": True}), } ) ) - assert config.domains["a.com"].related == {"shared.com": True} - assert config.domains["b.com"].related == {} + assert config.domains["a.com"].origins == {"shared.com": True} + assert config.domains["b.com"].origins == {} assert any("first domain wins" in w for w in warnings) def test_no_domains_is_fatal(self): @@ -446,10 +482,8 @@ class TestSanitizeConfig: with pytest.raises(ValueError, match="No servable domain"): domains.sanitize_config(Config(domains={"not a domain!": DomainConfig()})) - def test_build_tolerates_and_serves(self): - # Cross-domain entry stored in origins: served as a related origin + def test_build_serves_related_origin(self): reg = build_registry({"localhost": DomainConfig(origins={"example.com": True})}) - assert reg.warnings domain = reg.get("localhost") assert domain.related_origins == ["https://example.com"] domain.passkey.validate_origin("https://example.com") @@ -461,14 +495,15 @@ class TestSanitizeConfig: class TestOriginValidation: - """In-domain allow-list and related origins are separate concerns.""" + """The allow-list is explicit: empty allows nothing, wildcards cover + subtrees, related origins are additive exact matches.""" - def test_default_allows_whole_subtree(self): + def test_empty_allow_list_denies_all(self): p = Passkey(rp_id="example.com") - assert p.validate_origin("https://example.com") == "https://example.com" - assert p.validate_origin("https://app.example.com") with pytest.raises(ValueError, match="not allowed"): - p.validate_origin("https://other.com") + p.validate_origin("https://example.com") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://app.example.com") def test_allow_list_restricts_subtree(self): p = Passkey(rp_id="example.com", origins=["https://app.example.com"]) @@ -480,8 +515,9 @@ class TestOriginValidation: def test_related_origins_are_additive(self): p = Passkey(rp_id="example.com", related_origins=["https://app2.com"]) - assert p.validate_origin("https://app.example.com") # subtree stays open assert p.validate_origin("https://app2.com") + with pytest.raises(ValueError, match="not allowed"): + p.validate_origin("https://app.example.com") # nothing in-domain listed with pytest.raises(ValueError, match="not allowed"): p.validate_origin("https://other.com") @@ -500,19 +536,13 @@ class TestOriginValidation: with pytest.raises(ValueError, match="not allowed"): p.validate_origin("http://app.example.com:8080") - def test_star_entry_is_wildcard_shorthand(self): - """The bare '*' entry is shorthand for '*.{rp-id}' — https only.""" - p = Passkey(rp_id="example.com", origins=["*"]) - assert p.validate_origin("https://example.com") - assert p.validate_origin("https://app.example.com") - with pytest.raises(ValueError, match="not allowed"): - p.validate_origin("http://app.example.com:8080") - with pytest.raises(ValueError, match="not allowed"): - p.validate_origin("https://other.com") + def test_star_entry_rejected(self): + with pytest.raises(ValueError, match="Invalid origin"): + Passkey(rp_id="example.com", origins=["*"]) 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=["*"]) + 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("https://localhost") @@ -779,6 +809,14 @@ def _read_db(path) -> DB: return asyncio.run(_read()) +async def _write_legacy(src_file, config: LegacyConfig) -> None: + kanta = Kanta(str(src_file), LegacyDB()) + await kanta.open() + with kanta.transaction("test:seed"): + kanta.data.config = config + await kanta.close() + + class TestLegacyConversion: def test_convert_stamps_domain_everywhere(self, tmp_path): src = tmp_path / "example.com.paskiadb" @@ -830,6 +868,16 @@ class TestLegacyConversion: # The legacy OIDC provider carries over as the instance-global one assert converted.oidc.key == b"legacy-signing-key" + 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}'.""" + 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} + # ------------------------------------------------------------------------- # Transaction log censoring