diff --git a/paskia/db/structs.py b/paskia/db/structs.py index 3dd97b6..edd35c7 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -455,8 +455,11 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): except KeyError: return None + # Normalize host for comparison (stored hosts are already normalized) + normalized_input = normalize_host(host) + # Validate host matches (sessions are always created with a host) - if s.host != host: + if s.host != normalized_input: # Session bound to different host return None @@ -471,10 +474,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): # Effective permissions: role's permissions that the org can grant # Also filter by domain if host is provided org_perm_uuids = {p.uuid for p in org.permissions} - normalized_host = normalize_host(host) - host_without_port = ( - normalized_host.rsplit(":", 1)[0] if normalized_host else None - ) effective_perms = [] for perm_uuid in role.permission_set: @@ -484,8 +483,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): p = self.permissions[perm_uuid] except KeyError: continue - # Check domain restriction - if p.domain is not None and p.domain != host_without_port: + # Check domain restriction (normalized_input already has port stripped) + if p.domain is not None and p.domain != normalized_input: continue effective_perms.append(p) diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 11d9ac6..faa7ae1 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -7,7 +7,7 @@ from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.wschat import authenticate_and_login, register_chat from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.globals import passkey -from paskia.util import passphrase +from paskia.util import hostutil, passphrase # Create a FastAPI subapp for WebSocket endpoints app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) @@ -31,7 +31,7 @@ async def websocket_register_add( - Reset token supplied as ?reset=... (auth cookie ignored) """ origin = validate_origin(ws) - host = origin.split("://", 1)[1] + host = hostutil.normalize_host(origin.split("://", 1)[1]) if reset is not None: if not passphrase.is_well_formed(reset): raise ValueError( diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index 0a6aa08..e7c68bb 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -56,7 +56,7 @@ def reload_config() -> None: def normalize_host(raw_host: str | None) -> str | None: - """Normalize a Host header preserving port (exact match required).""" + """Normalize a Host header, stripping port numbers for consistent matching.""" if not raw_host: return None candidate = raw_host.strip() @@ -65,11 +65,12 @@ def normalize_host(raw_host: str | None) -> str | None: # urlsplit to parse (add // for scheme-less); prefer netloc to retain port. parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}") netloc = parsed.netloc or parsed.path or "" - # Strip IPv6 brackets around host part but retain port suffix. + # Handle IPv6 addresses: [ipv6]:port or [ipv6] if netloc.startswith("["): - # format: [ipv6]:port or [ipv6] if "]" in netloc: - host_part, _, rest = netloc.partition("]") - port_part = rest.lstrip(":") - netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "") + host_part, _, _ = netloc.partition("]") + netloc = host_part.strip("[]") + else: + # Strip port from host:port + netloc = netloc.rsplit(":", 1)[0] return netloc.lower() or None