* feat(#3351): add trusted-header auth core * fix(#3351): reconcile trusted sessions per request * fix(#3351): preserve valid trusted group mappings * fix(#3351): defer trusted logout reconciliation until after csrf * fix(auth): clear _pending_set_cookies in reset_trusted_auth_request_state (keep-alive cookie cross-request leak) Codex gate CORE finding: a Set-Cookie queued in request N but not flushed survived onto the reused HTTP/1.1 handler and could be emitted by request N+1 — after trusted-identity rotation on logout this could overwrite a valid login cookie and 401 the user. Reset the queue at the per-request boundary + add a keep-alive regression test. * docs(.env.example): document trusted-header auth / reverse-proxy SSO env vars HERMES_WEBUI_TRUSTED_AUTH_HEADER + TRUSTED_PROXY_CIDRS (with exact-proxy-IP security warning) + optional groups-header/JSON group-profile-map/logout-url. * Release exp-v0.52.43: trusted-header auth / reverse-proxy SSO (#5568, #3351) + keep-alive cookie fix + docs --------- Co-authored-by: Rod Boev <rod.boev@gmail.com> Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
This commit is contained in:
8 files changed
+1271
-46
No files matched your search
+359
-29
@@ -101,9 +101,33 @@ def _warn_auth_persistence_failure(prefix: str, artifact: Path, exc: Exception,
|
||||
|
||||
|
||||
_SESSIONS_FILE = STATE_DIR / '.sessions.json'
|
||||
_TRUSTED_AUTH_HEADER_ENV = 'HERMES_WEBUI_TRUSTED_AUTH_HEADER'
|
||||
_TRUSTED_GROUPS_HEADER_ENV = 'HERMES_WEBUI_TRUSTED_GROUPS_HEADER'
|
||||
_TRUSTED_GROUP_PROFILE_MAP_ENV = 'HERMES_WEBUI_GROUP_PROFILE_MAP'
|
||||
_TRUSTED_AUTH_LOGOUT_URL_ENV = 'HERMES_WEBUI_TRUSTED_AUTH_LOGOUT_URL'
|
||||
_TRUSTED_AUTH_WARNINGS_EMITTED: set[str] = set()
|
||||
|
||||
|
||||
def _load_sessions() -> dict[str, float]:
|
||||
def _warn_trusted_auth_once(key: str, message: str, *args) -> None:
|
||||
if key in _TRUSTED_AUTH_WARNINGS_EMITTED:
|
||||
return
|
||||
_TRUSTED_AUTH_WARNINGS_EMITTED.add(key)
|
||||
logger.warning(message, *args)
|
||||
|
||||
|
||||
def _session_expiry(record) -> float | None:
|
||||
if isinstance(record, dict):
|
||||
expiry = record.get('expiry', record.get('expires_at'))
|
||||
else:
|
||||
expiry = record
|
||||
try:
|
||||
expiry_f = float(expiry)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return expiry_f
|
||||
|
||||
|
||||
def _load_sessions() -> dict[str, float | dict]:
|
||||
"""Load persisted sessions from STATE_DIR, pruning expired entries.
|
||||
|
||||
Returns an empty dict on any read or parse error so startup is never
|
||||
@@ -141,11 +165,23 @@ def _load_sessions() -> dict[str, float]:
|
||||
)
|
||||
return {}
|
||||
now = time.time()
|
||||
return {t: exp for t, exp in data.items()
|
||||
if isinstance(t, str) and isinstance(exp, (int, float)) and exp > now}
|
||||
sessions: dict[str, float | dict] = {}
|
||||
for token, record in data.items():
|
||||
if not isinstance(token, str) or not token:
|
||||
continue
|
||||
expiry = _session_expiry(record)
|
||||
if expiry is None or expiry <= now:
|
||||
continue
|
||||
if isinstance(record, dict):
|
||||
normalized = dict(record)
|
||||
normalized['expiry'] = expiry
|
||||
sessions[token] = normalized
|
||||
else:
|
||||
sessions[token] = expiry
|
||||
return sessions
|
||||
|
||||
|
||||
def _save_sessions(sessions: dict[str, float]) -> None:
|
||||
def _save_sessions(sessions: dict[str, float | dict]) -> None:
|
||||
"""Atomically persist sessions to STATE_DIR/.sessions.json (0600).
|
||||
|
||||
Uses a temp file + os.replace() so a crash mid-write never leaves a
|
||||
@@ -500,11 +536,12 @@ def get_oidc_startup_warning() -> str | None:
|
||||
|
||||
|
||||
def is_auth_enabled() -> bool:
|
||||
"""True if password auth, passkeys, or OIDC login is configured."""
|
||||
"""True if password auth, passkeys, OIDC login, or trusted-header auth is configured."""
|
||||
return (
|
||||
is_password_auth_enabled()
|
||||
or are_passkeys_enabled()
|
||||
or is_oidc_auth_enabled()
|
||||
or is_trusted_auth_enabled()
|
||||
)
|
||||
|
||||
|
||||
@@ -539,11 +576,22 @@ def verify_password(plain: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def create_session() -> str:
|
||||
def create_session(*, auth_type: str | None = None, username: str | None = None, bound_profile: str | None = None) -> str:
|
||||
"""Create a new auth session. Returns signed cookie value."""
|
||||
token = secrets.token_hex(32)
|
||||
expiry = time.time() + _resolve_session_ttl()
|
||||
record: float | dict
|
||||
if any(value is not None for value in (auth_type, username, bound_profile)):
|
||||
record = {
|
||||
'expiry': expiry,
|
||||
'auth_type': auth_type,
|
||||
'username': username,
|
||||
'bound_profile': bound_profile,
|
||||
}
|
||||
else:
|
||||
record = expiry
|
||||
with _SESSIONS_LOCK:
|
||||
_sessions[token] = time.time() + _resolve_session_ttl()
|
||||
_sessions[token] = record
|
||||
_save_sessions(_sessions)
|
||||
sig = hmac.new(_signing_key(), token.encode(), hashlib.sha256).hexdigest()
|
||||
return f"{token}.{sig}"
|
||||
@@ -553,7 +601,7 @@ def _prune_expired_sessions():
|
||||
"""Remove all expired session entries to prevent unbounded memory growth."""
|
||||
now = time.time()
|
||||
with _SESSIONS_LOCK:
|
||||
expired = [t for t, exp in _sessions.items() if now > exp]
|
||||
expired = [t for t, record in _sessions.items() if (expiry := _session_expiry(record)) is None or now > expiry]
|
||||
if expired:
|
||||
for token in expired:
|
||||
_sessions.pop(token, None)
|
||||
@@ -576,14 +624,287 @@ def verify_session(cookie_value: str) -> bool:
|
||||
if not valid:
|
||||
return False
|
||||
with _SESSIONS_LOCK:
|
||||
expiry = _sessions.get(token)
|
||||
if not expiry or time.time() > expiry:
|
||||
expiry = _session_expiry(_sessions.get(token))
|
||||
if expiry is None or time.time() > expiry:
|
||||
_sessions.pop(token, None)
|
||||
_save_sessions(_sessions)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _trusted_auth_header_name() -> str | None:
|
||||
name = os.getenv(_TRUSTED_AUTH_HEADER_ENV, '').strip()
|
||||
if not name:
|
||||
return None
|
||||
if not _COOKIE_NAME_RE.match(name):
|
||||
_warn_trusted_auth_once(
|
||||
'trusted-auth-header',
|
||||
'Ignoring invalid %s=%r; trusted-header auth rejects every request',
|
||||
_TRUSTED_AUTH_HEADER_ENV,
|
||||
name,
|
||||
)
|
||||
return None
|
||||
return name
|
||||
|
||||
|
||||
def _trusted_auth_header_configured() -> bool:
|
||||
return bool(os.getenv(_TRUSTED_AUTH_HEADER_ENV, '').strip())
|
||||
|
||||
|
||||
def _trusted_group_profile_map() -> dict[str, str] | None:
|
||||
raw = os.getenv(_TRUSTED_GROUP_PROFILE_MAP_ENV, '').strip()
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
_warn_trusted_auth_once(
|
||||
'trusted-group-map',
|
||||
'Ignoring invalid %s JSON; trusted-header auth falls back to default profile binding',
|
||||
_TRUSTED_GROUP_PROFILE_MAP_ENV,
|
||||
)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
_warn_trusted_auth_once(
|
||||
'trusted-group-map-type',
|
||||
'Ignoring non-dict %s; trusted-header auth falls back to default profile binding',
|
||||
_TRUSTED_GROUP_PROFILE_MAP_ENV,
|
||||
)
|
||||
return {}
|
||||
mapping: dict[str, str] = {}
|
||||
for group, profile in data.items():
|
||||
group_name = str(group or '').strip()
|
||||
profile_name = str(profile or '').strip()
|
||||
if not group_name or not profile_name:
|
||||
_warn_trusted_auth_once(
|
||||
'trusted-group-map-entry',
|
||||
'Ignoring invalid entry in %s; trusted-header auth falls back to default profile binding',
|
||||
_TRUSTED_GROUP_PROFILE_MAP_ENV,
|
||||
)
|
||||
continue
|
||||
mapping[group_name] = profile_name
|
||||
return mapping
|
||||
|
||||
|
||||
def _trusted_groups_header_value(handler) -> list[str]:
|
||||
header_name = os.getenv(_TRUSTED_GROUPS_HEADER_ENV, '').strip()
|
||||
if not header_name:
|
||||
return []
|
||||
try:
|
||||
raw = handler.headers.get(header_name, '')
|
||||
except Exception:
|
||||
return []
|
||||
if not raw:
|
||||
return []
|
||||
values = []
|
||||
for part in str(raw).replace('\n', ',').split(','):
|
||||
part = part.strip()
|
||||
if part:
|
||||
values.append(part)
|
||||
return values
|
||||
|
||||
|
||||
def _trusted_auth_username(handler) -> str | None:
|
||||
header_name = _trusted_auth_header_name()
|
||||
if not header_name:
|
||||
return None
|
||||
try:
|
||||
raw = handler.headers.get(header_name, '')
|
||||
except Exception:
|
||||
return None
|
||||
username = str(raw or '').strip()
|
||||
return username or None
|
||||
|
||||
|
||||
def _trusted_auth_bound_profile(handler) -> str | None:
|
||||
mapping = _trusted_group_profile_map()
|
||||
if mapping is None:
|
||||
return None
|
||||
groups = set(_trusted_groups_header_value(handler))
|
||||
for group, profile in mapping.items():
|
||||
if group in groups:
|
||||
return profile
|
||||
return 'default'
|
||||
|
||||
|
||||
def _queue_pending_cookie(handler, cookie_header: str) -> None:
|
||||
if not cookie_header:
|
||||
return
|
||||
pending = getattr(handler, '_pending_set_cookies', None)
|
||||
if pending is None:
|
||||
pending = []
|
||||
handler._pending_set_cookies = pending
|
||||
pending.append(cookie_header)
|
||||
|
||||
|
||||
def _auth_cookie_header(cookie_value, handler=None) -> str:
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
name = _resolve_cookie_name()
|
||||
cookie[name] = cookie_value
|
||||
cookie[name]['httponly'] = True
|
||||
cookie[name]['samesite'] = 'Lax'
|
||||
cookie[name]['path'] = '/'
|
||||
cookie[name]['max-age'] = str(_resolve_session_ttl())
|
||||
if _is_secure_context(handler):
|
||||
cookie[name]['secure'] = True
|
||||
return cookie[name].OutputString()
|
||||
|
||||
|
||||
def _clear_auth_cookie_header() -> str:
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
name = _resolve_cookie_name()
|
||||
cookie[name] = ''
|
||||
cookie[name]['httponly'] = True
|
||||
cookie[name]['path'] = '/'
|
||||
cookie[name]['samesite'] = 'Lax'
|
||||
cookie[name]['max-age'] = '0'
|
||||
return cookie[name].OutputString()
|
||||
|
||||
|
||||
def _build_profile_cookie_header(name: str, session_cookie_value: str | None) -> str:
|
||||
from api.helpers import build_profile_cookie
|
||||
|
||||
return build_profile_cookie(name, session_cookie_value=session_cookie_value)
|
||||
|
||||
|
||||
def _request_profile_matches_bound(bound_profile: str | None) -> bool:
|
||||
if not bound_profile:
|
||||
return True
|
||||
try:
|
||||
from api.profiles import get_active_profile_name, _profiles_match
|
||||
|
||||
return _profiles_match(bound_profile, get_active_profile_name())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_session_info(cookie_value: str) -> dict | None:
|
||||
if not verify_session(cookie_value):
|
||||
return None
|
||||
token = _session_token_from_cookie_value(cookie_value)
|
||||
if not token:
|
||||
return None
|
||||
with _SESSIONS_LOCK:
|
||||
record = _sessions.get(token)
|
||||
expiry = _session_expiry(record)
|
||||
if expiry is None:
|
||||
return None
|
||||
info: dict[str, object] = {'token': token, 'expiry': expiry}
|
||||
if isinstance(record, dict):
|
||||
info.update({k: v for k, v in record.items() if k != 'expiry'})
|
||||
if 'bound_profile' not in info and isinstance(info.get('profile'), str):
|
||||
info['bound_profile'] = info.get('profile')
|
||||
info.setdefault('auth_type', None)
|
||||
info.setdefault('username', None)
|
||||
info.setdefault('bound_profile', None)
|
||||
return info
|
||||
|
||||
|
||||
def session_bound_profile(cookie_value: str) -> str | None:
|
||||
info = get_session_info(cookie_value)
|
||||
if not info:
|
||||
return None
|
||||
bound_profile = info.get('bound_profile')
|
||||
bound_profile = str(bound_profile or '').strip()
|
||||
return bound_profile or None
|
||||
|
||||
|
||||
def is_trusted_auth_enabled() -> bool:
|
||||
return _trusted_auth_header_configured()
|
||||
|
||||
|
||||
def get_trusted_auth_logout_url() -> str | None:
|
||||
value = os.getenv(_TRUSTED_AUTH_LOGOUT_URL_ENV, '').strip()
|
||||
return value or None
|
||||
|
||||
|
||||
def _remember_trusted_auth_session(handler, info: dict | None, cookie_value: str | None = None) -> dict | None:
|
||||
handler._trusted_auth_session_reconciled = info
|
||||
if info and info.get('auth_type') == 'trusted':
|
||||
handler._trusted_auth_session_info = info
|
||||
handler._trusted_auth_session_cookie_value = cookie_value
|
||||
return info
|
||||
|
||||
|
||||
def reset_trusted_auth_request_state(handler) -> None:
|
||||
for name in (
|
||||
'_trusted_auth_session_reconciled',
|
||||
'_trusted_auth_session_rejected',
|
||||
'_trusted_auth_session_info',
|
||||
'_trusted_auth_session_cookie_value',
|
||||
# Clear any auth cookie queued by a prior request but not yet flushed.
|
||||
# The handler is reused across HTTP/1.1 keep-alive requests, so a stale
|
||||
# queued Set-Cookie would otherwise cross the request boundary and be
|
||||
# emitted by a later response — e.g. after trusted-identity rotation on
|
||||
# logout it could overwrite a subsequent valid login cookie and 401 the
|
||||
# user. Reset it at the per-request boundary (server.py do_GET/do_POST).
|
||||
'_pending_set_cookies',
|
||||
):
|
||||
try:
|
||||
delattr(handler, name)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
def _apply_trusted_session_profile(handler, bound_profile: str | None, cookie_value: str) -> None:
|
||||
if bound_profile is None:
|
||||
return
|
||||
from api.helpers import get_profile_cookie
|
||||
from api.profiles import set_request_profile
|
||||
|
||||
set_request_profile(bound_profile)
|
||||
if get_profile_cookie(handler) != bound_profile:
|
||||
_queue_pending_cookie(handler, _build_profile_cookie_header(bound_profile, cookie_value))
|
||||
|
||||
|
||||
def ensure_trusted_auth_session(handler) -> dict | None:
|
||||
if hasattr(handler, '_trusted_auth_session_reconciled'):
|
||||
return handler._trusted_auth_session_reconciled
|
||||
cookie_value = parse_cookie(handler)
|
||||
info = get_session_info(cookie_value) if cookie_value and verify_session(cookie_value) else None
|
||||
if info and info.get('auth_type') != 'trusted':
|
||||
return _remember_trusted_auth_session(handler, info)
|
||||
if not is_trusted_auth_enabled():
|
||||
if info:
|
||||
invalidate_session(cookie_value)
|
||||
handler._trusted_auth_session_rejected = True
|
||||
return _remember_trusted_auth_session(handler, None)
|
||||
from api.routes import _raw_peer_is_trusted_proxy
|
||||
|
||||
if not _raw_peer_is_trusted_proxy(handler):
|
||||
if info:
|
||||
invalidate_session(cookie_value)
|
||||
handler._trusted_auth_session_rejected = True
|
||||
return _remember_trusted_auth_session(handler, None)
|
||||
username = _trusted_auth_username(handler)
|
||||
if not username:
|
||||
if info:
|
||||
invalidate_session(cookie_value)
|
||||
handler._trusted_auth_session_rejected = True
|
||||
return _remember_trusted_auth_session(handler, None)
|
||||
bound_profile = _trusted_auth_bound_profile(handler)
|
||||
if info and info.get('username') == username and info.get('bound_profile') == bound_profile:
|
||||
_apply_trusted_session_profile(handler, bound_profile, cookie_value)
|
||||
return _remember_trusted_auth_session(handler, info, cookie_value)
|
||||
if info:
|
||||
invalidate_session(cookie_value)
|
||||
cookie_value = create_session(
|
||||
auth_type='trusted',
|
||||
username=username,
|
||||
bound_profile=bound_profile,
|
||||
)
|
||||
_queue_pending_cookie(handler, _auth_cookie_header(cookie_value, handler))
|
||||
_apply_trusted_session_profile(handler, bound_profile, cookie_value)
|
||||
info = get_session_info(cookie_value)
|
||||
return _remember_trusted_auth_session(handler, info, cookie_value)
|
||||
|
||||
|
||||
def trusted_session_allows_active_profile(info: dict | None) -> bool:
|
||||
if not info:
|
||||
return True
|
||||
return _request_profile_matches_bound(str(info.get('bound_profile') or '') or None)
|
||||
|
||||
|
||||
def _session_token_from_cookie_value(cookie_value: str) -> str | None:
|
||||
"""Return the raw server-side session token from a signed cookie value."""
|
||||
if not cookie_value or '.' not in cookie_value:
|
||||
@@ -743,9 +1064,33 @@ def check_auth(handler, parsed) -> bool:
|
||||
or parsed.path.startswith('/session/static/')
|
||||
):
|
||||
return True
|
||||
# Check session cookie
|
||||
cookie_val = parse_cookie(handler)
|
||||
if cookie_val and verify_session(cookie_val):
|
||||
has_session = bool(cookie_val and verify_session(cookie_val))
|
||||
if parsed.path == '/api/auth/logout':
|
||||
if has_session:
|
||||
return True
|
||||
body = b'{"error":"Authentication required"}'
|
||||
handler.send_response(401)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
return False
|
||||
session_info = ensure_trusted_auth_session(handler)
|
||||
if session_info:
|
||||
if not trusted_session_allows_active_profile(session_info):
|
||||
if parsed.path.startswith('/api/'):
|
||||
body = b'{"error":"Profile access forbidden"}'
|
||||
handler.send_response(403)
|
||||
handler.send_header('Content-Type', 'application/json')
|
||||
else:
|
||||
body = b'Profile access forbidden'
|
||||
handler.send_response(403)
|
||||
handler.send_header('Content-Type', 'text/plain; charset=utf-8')
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
return False
|
||||
return True
|
||||
# Not authorized
|
||||
if parsed.path.startswith('/api/'):
|
||||
@@ -866,24 +1211,9 @@ def _is_secure_context(handler=None) -> bool:
|
||||
|
||||
def set_auth_cookie(handler, cookie_value) -> None:
|
||||
"""Set the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
name = _resolve_cookie_name()
|
||||
cookie[name] = cookie_value
|
||||
cookie[name]['httponly'] = True
|
||||
cookie[name]['samesite'] = 'Lax'
|
||||
cookie[name]['path'] = '/'
|
||||
cookie[name]['max-age'] = str(_resolve_session_ttl())
|
||||
if _is_secure_context(handler):
|
||||
cookie[name]['secure'] = True
|
||||
handler.send_header('Set-Cookie', cookie[name].OutputString())
|
||||
handler.send_header('Set-Cookie', _auth_cookie_header(cookie_value, handler))
|
||||
|
||||
|
||||
def clear_auth_cookie(handler) -> None:
|
||||
"""Clear the auth cookie on the response."""
|
||||
cookie = http.cookies.SimpleCookie()
|
||||
name = _resolve_cookie_name()
|
||||
cookie[name] = ''
|
||||
cookie[name]['httponly'] = True
|
||||
cookie[name]['path'] = '/'
|
||||
cookie[name]['max-age'] = '0'
|
||||
handler.send_header('Set-Cookie', cookie[name].OutputString())
|
||||
handler.send_header('Set-Cookie', _clear_auth_cookie_header())
|
||||
Reference in new issue
Block a user