Compare commits

...
3 Commits
Author SHA1 Message Date
LeoVasanko 17abcc48c0 Replace ua-parser wrapper with uarite uaparse 2026-09-09 19:22:59 +00:00
LeoVasanko 97ce10dd6f Improved formatting of origin configuration in startup box. 2026-09-09 19:10:01 +00:00
LeoVasanko 3a7ba09ddd Fix legacy conversion dropping 'empty origins = allow all' when an auth host was set
The **.{rp-id} wildcard was only added when the resulting origins dict
was empty, so a legacy database with a dedicated auth host but no
configured origins ended up allowing only the auth host.
2026-09-09 17:35:04 +00:00
7 changed files with 66 additions and 39 deletions
+3 -2
View File
@@ -128,9 +128,10 @@ def _legacy_to_db(old: LegacyDB) -> DB:
origins[origin_key(origin)] = True
if old.config.auth_host:
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
if not origins:
if not old.config.origins:
# Legacy semantics: no origins configured = the whole rp-id domain
# allowed. The new format requires explicit entries.
# allowed, regardless of a dedicated auth host. The new format
# requires explicit entries.
origins[f"**.{rp_id}"] = True
new_config = Config(
+3 -4
View File
@@ -15,6 +15,7 @@ from uuid import UUID
import base64url
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from uarite import uaparse
from paskia import authcode, db, remoteauth
from paskia.authcode import CookieCode
@@ -23,7 +24,7 @@ from paskia.domains import current_domain, registry
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import authenticate_and_login
from paskia.fastapi.wsutil import validate_origin, websocket_error_handler
from paskia.util import pow, useragent
from paskia.util import pow
# Create a FastAPI subapp for remote auth WebSocket endpoints
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -458,9 +459,7 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE):
if requesting_domain
else request.rp_id
),
"user_agent_pretty": useragent.compact_user_agent(
request.user_agent
),
"user_agent_pretty": uaparse(request.user_agent).pretty,
"client_ip": request.ip,
"action": request.action,
"pow": {
+2 -2
View File
@@ -11,10 +11,10 @@ from datetime import datetime
from uuid import UUID
import msgspec
from uarite import uaparse
from paskia import db
from paskia.db.structs import Credential, Org, OriginEntry, Permission, Role, User
from paskia.util import useragent
# -------------------------------------------------------------------------
# API structs - inherit from db structs, add uuid for serialization
@@ -124,7 +124,7 @@ class ApiUserSession(msgspec.Struct, omit_defaults=True):
credential_uuid=s.credential_uuid,
host=s.host,
ip=s.ip,
user_agent=useragent.compact_user_agent(s.user_agent),
user_agent=uaparse(s.user_agent).pretty,
validated=s.validated,
last_renewed=s.validated,
is_current=s.key == current_key,
+41 -1
View File
@@ -6,11 +6,13 @@ import os
import re
from sys import stderr
from typing import TYPE_CHECKING
from urllib.parse import urlparse
from fastapi_vue.hostutil import parse_endpoints
from paskia._version import __version__
from paskia.domains import auth_host_url, origin_url, partition_origins
from paskia.util import hostutil
from paskia.util.constants import DEFAULT_PORT, DEVMODE
from paskia.util.hostutil import format_endpoint, wildcard_base
@@ -85,9 +87,47 @@ def _origin_phrase(key: str, rp_id: str) -> str:
return _compact_url(origin_url(key))
def _covered_by_wildcard(key: str, pattern: str) -> bool:
"""Whether an origins-table key is redundant given a wildcard key.
Mirrors DomainConfig matching (sansio._allowlisted): a wildcard covers
hostnames under its base over https (any port), except under localhost
where any scheme and any port match. Plain http entries outside
localhost are therefore never covered and stay listed.
"""
base = wildcard_base(pattern)
if base is None:
return False
# Keys are bare hosts (https:// and '/' stripped by origin_key, port
# kept) or full origins; urlparse needs a scheme or '//' prefix.
hostname = urlparse(key if "://" in key else f"//{key}").hostname
if not hostname:
return False
if pattern.startswith("**."):
matched = hostutil.is_subdomain(hostname, base)
else:
# '*.base' covers exactly one subdomain level
matched = hostname.endswith(f".{base}") and "." not in hostname[
: -len(base) - 1
]
if not matched:
return False
if hostutil.is_subdomain(base, "localhost"):
return True # localhost: any scheme, any port
return "://" not in key or key.startswith("https://")
def _signin_summary(in_domain: list[str], rp_id: str) -> str:
"""Compact summary of a domain's in-domain sign-in sites."""
phrases = [_origin_phrase(key, rp_id) for key in sorted(in_domain)]
# Prune entries already covered by a reported wildcard (e.g. the auth
# host under '**.{rp-id}'); http origins outside localhost survive.
wildcards = [k for k in in_domain if wildcard_base(k)]
keys = [
k
for k in in_domain
if wildcard_base(k) or not any(_covered_by_wildcard(k, w) for w in wildcards)
]
phrases = [_origin_phrase(key, rp_id) for key in sorted(keys)]
if len(phrases) > 2:
n = len(phrases) - 1
return f"{phrases[0]}, +{n} site{'s' if n > 1 else ''}"
-29
View File
@@ -1,29 +0,0 @@
from ua_parser import parse
def compact_user_agent(ua: str | None) -> str:
"""Format user agent string into a compact display format.
Returns empty string for empty/missing user agents.
Returns original UA for unrecognized ones.
"""
if not ua or not ua.strip() or ua == "-":
return ""
r = parse(ua)
browser = r.user_agent.family if r.user_agent else None
ver = r.user_agent.major if r.user_agent else ""
os_name = r.os.family if r.os else None
dev = r.device.family if r.device else None
# If browser is unrecognized, return original UA
if browser in (None, "Other") and os_name in (None, "Other"):
return ua
# Filter out "Other" values
browser = browser if browser and browser != "Other" else ""
os_name = os_name if os_name and os_name != "Other" else ""
# Exclude device if it's "Other" or matches browser family (parser bug)
if dev in (None, "Other") or dev == browser:
dev = ""
# Build compact string, filtering empty parts
parts = [f"{browser}/{ver}" if browser else "", os_name, dev]
result = " ".join(p for p in parts if p).strip()
return result
+1 -1
View File
@@ -22,8 +22,8 @@ dependencies = [
"jsondiff>=2.2.1",
"msgspec>=0.20.0",
"fastapi-vue~=1.4.2",
"ua-parser[regex]>=1.0.1",
"kanta>=0.7.0",
"uarite>=0.2.1",
]
[dependency-groups]
dev = [
+16
View File
@@ -941,6 +941,22 @@ class TestLegacyConversion:
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
assert config.domains["example.com"].origins == {"**.example.com": True}
def test_convert_auth_host_with_empty_origins_keeps_wildcard(self, tmp_path):
"""A dedicated auth host with no configured origins still allowed
the whole rp-id domain in the legacy format — the auth host must
not become the only allowed origin."""
src_file = tmp_path / "main.db"
asyncio.run(
_write_legacy(
src_file,
LegacyConfig(rp_id="example.com", auth_host="auth.example.com"),
)
)
config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb")
origins = config.domains["example.com"].origins
assert origins["**.example.com"] is True
assert origins["auth.example.com"] == OriginEntry(auth_host=True)
# -------------------------------------------------------------------------
# Transaction log censoring