Improved client IP and UA handling.

This commit is contained in:
Leo Vasanko
2026-02-05 18:02:23 +00:00
parent e1f6c85ced
commit fa378eb82e
7 changed files with 96 additions and 16 deletions
+1 -1
View File
@@ -53,7 +53,7 @@
<!-- Device info display (shown when 3 words match a request) -->
<div v-else-if="deviceInfo" class="device-info">
<p class="device-permit-text">Permit {{ deviceInfo.action === 'register' ? 'registration' : 'login' }} to <strong>{{ deviceInfo.host }}</strong></p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty }}</p>
<p class="device-meta">{{ deviceInfo.user_agent_pretty || '—' }}</p>
<p v-if="error" class="error-message" style="margin-top: 0.5rem;">{{ error }}</p>
+1 -1
View File
@@ -30,7 +30,7 @@
@keydown="handleItemKeydown($event, session)"
>
<div class="item-top">
<h4 class="item-title">{{ session.user_agent }}</h4>
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
+2 -2
View File
@@ -17,7 +17,7 @@ from paskia import db
from paskia.authsession import EXPIRES, expires, get_reset
from paskia.fastapi import authz, session, user
from paskia.fastapi.response import MsgspecResponse
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME
from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
@@ -91,7 +91,7 @@ async def validate_token(
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session(
auth,
ip=request.client.host if request.client else "",
ip=get_client_ip(request),
user_agent=request.headers.get("user-agent") or "",
expiry=expires(),
ctx=ctx,
+28 -3
View File
@@ -38,10 +38,33 @@ _AUTHZ_GRANTED = "\033[0;32m" # Granted scope (green)
def format_ipv6_network(ip: str) -> str:
"""Format IPv6 address to show only network part (first 64 bits)."""
"""Format IPv6 address to show only network part (first 64 bits).
Special addresses are returned as-is for clarity:
- ::1 (loopback)
- :: (unspecified)
- ::ffff:x.x.x.x (IPv4-mapped, returns just the IPv4 part)
- fe80:: (link-local, returned as-is since interface-specific)
"""
try:
# Strip brackets that some proxies add around IPv6
ip = ip.strip("[]")
# Strip zone ID (e.g., fe80::1%eth0)
if "%" in ip:
ip = ip.split("%")[0]
addr = IPv6Address(ip)
# Get the integer representation and mask to first 64 bits
# Special cases - return as-is or with minimal processing
if addr.is_loopback: # ::1
return "::1"
if addr.is_unspecified: # ::
return "::"
if addr.ipv4_mapped: # ::ffff:x.x.x.x
return str(addr.ipv4_mapped)
if addr.is_link_local: # fe80::/10 - interface-specific, keep full
return str(addr)
# Regular addresses: truncate to /64 network prefix
network_int = int(addr) >> 64
# Format as IPv6 with trailing ::
# Split into 4 groups of 16 bits
@@ -61,7 +84,9 @@ def format_client_ip(ip: str) -> str:
"""Format client IP, compressing IPv6 to network part only."""
if not ip or ip == "-":
return "-"
if ":" in ip:
# Strip brackets for detection (some proxies add them)
stripped = ip.strip("[]")
if ":" in stripped:
return format_ipv6_network(ip)
return ip
+37 -1
View File
@@ -8,6 +8,8 @@ This module provides FastAPI-specific session management functionality:
Generic session management functions have been moved to authsession.py
"""
from ipaddress import IPv4Address, IPv6Address
from fastapi import Cookie, Request, Response, WebSocket
from paskia.authsession import EXPIRES
@@ -16,10 +18,44 @@ AUTH_COOKIE_NAME = "__Host-paskia"
AUTH_COOKIE = Cookie(None, alias=AUTH_COOKIE_NAME)
def normalize_ip(ip: str) -> str:
"""Normalize IP address, stripping brackets and validating format.
Proxies may pass IPv6 in brackets like [::1] or with zone IDs.
IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) are converted to plain IPv4.
Returns empty string for invalid addresses.
"""
if not ip:
return ""
# Strip brackets that some proxies add around IPv6
ip = ip.strip("[]")
# Strip zone ID (e.g., fe80::1%eth0)
if "%" in ip:
ip = ip.split("%")[0]
try:
# Validate and normalize
if ":" in ip:
addr = IPv6Address(ip)
# Convert IPv4-mapped addresses to plain IPv4
if addr.ipv4_mapped:
return str(addr.ipv4_mapped)
return str(addr)
return str(IPv4Address(ip))
except ValueError:
return ip # Return as-is if not a valid IP (could be hostname)
def get_client_ip(request: Request | WebSocket) -> str:
"""Get client IP from request, normalized."""
if not request.client:
return ""
return normalize_ip(request.client.host)
def infodict(request: Request | WebSocket, type: str) -> dict:
"""Extract client information from request."""
return {
"ip": request.client.host if request.client else "",
"ip": get_client_ip(request),
"user_agent": request.headers.get("user-agent", "")[:500],
"session_type": type,
}
+26 -7
View File
@@ -1,10 +1,29 @@
import user_agents
from ua_parser import parse
def compact_user_agent(ua: str | None) -> str:
if not ua:
return "-"
u = user_agents.parse(ua)
ver = u.browser.version_string.split(".")[0]
dev = u.device.family if u.device.family not in ["Other", "Mac"] else ""
return f"{u.browser.family}/{ver} {u.os.family} {dev}".strip()
"""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
@@ -18,11 +18,11 @@ dependencies = [
"base64url>=1.0.0",
"uuid7-standard>=1.0.0",
"pyjwt>=2.8.0",
"user-agents>=2.2.0",
"jsondiff>=2.2.1",
"msgspec>=0.20.0",
"aiofiles>=25.1.0",
"fastapi-vue>=0.3.0",
"ua-parser[regex]>=1.0.1",
]
requires-python = ">=3.11"