Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17abcc48c0 | ||
|
|
97ce10dd6f | ||
|
|
3a7ba09ddd |
+3
-2
@@ -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(
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ''}"
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user