Merge pull request #4036 from nesquena/stage-4023

Release MG — v0.51.368 — bind active-profile cookie to auth session (#4023, fixes #803)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-12 00:46:01 -07:00
commit 9e96f5f6ad
5 files changed
+232 -14

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.368] — 2026-06-12 — Release MG (bind active-profile cookie to auth session)
### Security
- **The active-profile cookie is now cryptographically bound to the auth session when auth is enabled (#803).** The `hermes_profile` cookie is client-controlled and is read by profile-scoped routes to decide which profile's data is visible. Previously, with auth enabled, a client could forge `hermes_profile=<other-profile>` to have requests treated under another profile. The cookie is now HMAC-signed over both the session token and the profile name, so it can't be forged, replayed across sessions, or altered to another profile; verification fails closed (an unsigned, tampered, or stale plain-name cookie is rejected and the request falls back to the default profile). No-auth deployments keep the legacy plain-name cookie, which was never an authorization boundary there. As a side benefit, an unauthenticated client can no longer influence the profile context that pre-login (public-path) handlers run under. (#803)
## [v0.51.367] — 2026-06-12 — Release MF (autocomplete filter + lineage merge + shutdown i18n fixes)
### Fixed
+47
View File
@@ -467,6 +467,53 @@ def _session_token_from_cookie_value(cookie_value: str) -> str | None:
return token or None
def sign_profile_cookie_value(profile_name: str, session_cookie_value: str | None) -> str:
"""Return a profile cookie value authenticated for one WebUI session.
The active-profile cookie is client-controlled, so when auth is enabled it
must not be trusted as a bare profile name. Binding the selected profile to
the HttpOnly session token prevents a client from forging
``hermes_profile=<other-profile>`` and bypassing profile visibility guards.
"""
if not session_cookie_value or not verify_session(session_cookie_value):
raise ValueError("active auth session is required to sign profile cookie")
token = _session_token_from_cookie_value(session_cookie_value)
if not token:
raise ValueError("active auth session is required to sign profile cookie")
sig = hmac.new(
_signing_key(),
f"profile:{token}:{profile_name}".encode(),
hashlib.sha256,
).hexdigest()
return f"{profile_name}.{sig}"
def verify_profile_cookie_value(cookie_value: str, session_cookie_value: str | None) -> str | None:
"""Verify a session-bound profile cookie and return its profile name."""
if not cookie_value or '.' not in cookie_value:
return None
if not session_cookie_value or not verify_session(session_cookie_value):
return None
profile_name, sig = cookie_value.rsplit('.', 1)
token = _session_token_from_cookie_value(session_cookie_value)
if not profile_name or not token or not sig:
return None
# Defense-in-depth: validate the profile-name pattern here too, not only in
# get_profile_cookie(), so any future caller of this verifier can't return an
# unvalidated name. (#4023 Opus hardening.)
from api.profiles import _PROFILE_ID_RE
if profile_name != 'default' and not _PROFILE_ID_RE.fullmatch(profile_name):
return None
expected = hmac.new(
_signing_key(),
f"profile:{token}:{profile_name}".encode(),
hashlib.sha256,
).hexdigest()
if hmac.compare_digest(str(sig), expected):
return profile_name
return None
def csrf_token_for_session(cookie_value: str) -> str | None:
"""Return the CSRF token bound to an authenticated WebUI session.
+51 -10
View File
@@ -503,7 +503,14 @@ def get_profile_cookie_name() -> str:
def get_profile_cookie(handler) -> str | None:
"""Extract the active-profile cookie value from the request, or None."""
"""Extract and authenticate the active-profile cookie value.
When WebUI auth is enabled, the profile cookie is treated as an
authorization input for profile-scoped routes. Require it to be signed for
the current auth session so clients cannot forge ``hermes_profile`` to
impersonate another profile. In no-auth deployments, keep the historical
plain profile-name cookie behavior.
"""
cookie_header = handler.headers.get('Cookie', '')
if not cookie_header:
return None
@@ -515,16 +522,30 @@ def get_profile_cookie(handler) -> str | None:
return None
cookie_name = get_profile_cookie_name()
morsel = cookie.get(cookie_name)
if morsel and morsel.value:
# Validate against profile-name pattern before trusting
from api.profiles import _PROFILE_ID_RE
val = morsel.value
if val == 'default' or _PROFILE_ID_RE.fullmatch(val):
return val
return None
if not (morsel and morsel.value):
return None
from api.profiles import _PROFILE_ID_RE
def _valid_profile_name(val: str) -> bool:
return val == 'default' or bool(_PROFILE_ID_RE.fullmatch(val))
raw_val = morsel.value
try:
from api.auth import is_auth_enabled, parse_cookie, verify_profile_cookie_value
if is_auth_enabled():
val = verify_profile_cookie_value(raw_val, parse_cookie(handler))
return val if val and _valid_profile_name(val) else None
except Exception:
logger.warning("Failed to verify active profile cookie", exc_info=True)
return None
# No-auth mode: the cookie is a per-browser UI preference, not an authz
# boundary, so retain the legacy plain profile-name format.
return raw_val if _valid_profile_name(raw_val) else None
def build_profile_cookie(name: str) -> str:
def build_profile_cookie(name: str, handler=None) -> str:
"""Build a Set-Cookie header value for the active-profile cookie.
Always persist the selected profile in the cookie, including 'default'.
@@ -539,7 +560,27 @@ def build_profile_cookie(name: str) -> str:
import http.cookies as _hc
cookie = _hc.SimpleCookie()
cookie_name = get_profile_cookie_name()
cookie[cookie_name] = name
value = name
# Guard against a future call site silently emitting an UNSIGNED profile
# cookie while auth is enabled (which a client could then... not forge, but
# it would weaken the binding). If auth is on we require a handler so the
# cookie is bound to the session. (#4023 Opus hardening.)
try:
from api.auth import is_auth_enabled
_auth_on = is_auth_enabled()
except Exception:
_auth_on = False
if _auth_on and handler is None:
raise RuntimeError("build_profile_cookie requires a request handler when auth is enabled (to bind the profile cookie to the session)")
if handler is not None:
try:
from api.auth import is_auth_enabled, parse_cookie, sign_profile_cookie_value
if is_auth_enabled():
value = sign_profile_cookie_value(name, parse_cookie(handler))
except Exception as exc:
logger.warning("Failed to sign active profile cookie", exc_info=True)
raise RuntimeError("could not sign active profile cookie") from exc
cookie[cookie_name] = value
cookie[cookie_name]['path'] = '/'
cookie[cookie_name]['httponly'] = True
cookie[cookie_name]['samesite'] = 'Lax'
+1 -1
View File
@@ -8362,7 +8362,7 @@ def handle_post(handler, parsed) -> bool:
from api.config import invalidate_models_cache
invalidate_models_cache()
return j(handler, result, extra_headers={
'Set-Cookie': build_profile_cookie(name),
'Set-Cookie': build_profile_cookie(name, handler),
})
except (ValueError, FileNotFoundError) as e:
return bad(handler, _sanitize_error(e), 404)
+127 -3
View File
@@ -45,21 +45,108 @@ class TestProfileCookieHelpers:
handler.headers.get = lambda k, d='': ''
assert get_profile_cookie(handler) is None
def test_get_profile_cookie_extracts_valid_name(self):
def test_get_profile_cookie_extracts_valid_name(self, monkeypatch):
from api.helpers import get_profile_cookie
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
handler = MagicMock()
handler.headers.get = lambda k, d='': 'hermes_profile=alice' if k == 'Cookie' else d
assert get_profile_cookie(handler) == 'alice'
def test_get_profile_cookie_accepts_default(self):
def test_get_profile_cookie_requires_session_bound_signature_when_auth_enabled(self, monkeypatch):
from api.auth import sign_profile_cookie_value
from api.helpers import get_profile_cookie
session_cookie = 'session-token.session-sig'
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
monkeypatch.setattr('api.auth.verify_session', lambda cookie: cookie == session_cookie)
signed_profile = sign_profile_cookie_value('alice', session_cookie)
handler = MagicMock()
handler.headers.get = lambda k, d='': (
f'hermes_session={session_cookie}; hermes_profile={signed_profile}' if k == 'Cookie' else d
)
assert get_profile_cookie(handler) == 'alice'
def test_get_profile_cookie_rejects_unsigned_profile_when_auth_enabled(self, monkeypatch):
from api.helpers import get_profile_cookie
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
handler = MagicMock()
handler.headers.get = lambda k, d='': (
'hermes_session=session-token.session-sig; hermes_profile=alice' if k == 'Cookie' else d
)
assert get_profile_cookie(handler) is None
def test_get_profile_cookie_rejects_profile_signed_for_another_session(self, monkeypatch):
from api.auth import sign_profile_cookie_value
from api.helpers import get_profile_cookie
other_session = 'other-token.other-sig'
current_session = 'session-token.session-sig'
monkeypatch.setattr('api.auth.verify_session', lambda cookie: cookie in {other_session, current_session})
signed_profile = sign_profile_cookie_value('alice', other_session)
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
handler = MagicMock()
handler.headers.get = lambda k, d='': (
f'hermes_session={current_session}; hermes_profile={signed_profile}' if k == 'Cookie' else d
)
assert get_profile_cookie(handler) is None
def test_build_profile_cookie_binds_to_auth_session_when_auth_enabled(self, monkeypatch):
from api.auth import verify_profile_cookie_value
from api.helpers import build_profile_cookie
session_cookie = 'session-token.session-sig'
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
monkeypatch.setattr('api.auth.verify_session', lambda cookie: cookie == session_cookie)
handler = MagicMock()
handler.headers.get = lambda k, d='': f'hermes_session={session_cookie}' if k == 'Cookie' else d
cookie = build_profile_cookie('alice', handler)
value = cookie.split('hermes_profile=', 1)[1].split(';', 1)[0]
assert value != 'alice'
assert verify_profile_cookie_value(value, session_cookie) == 'alice'
def test_sign_profile_cookie_requires_active_session(self, monkeypatch):
from api.auth import sign_profile_cookie_value
monkeypatch.setattr('api.auth.verify_session', lambda cookie: False)
with pytest.raises(ValueError):
sign_profile_cookie_value('alice', 'expired-token.session-sig')
def test_verify_profile_cookie_rejects_expired_session(self, monkeypatch):
from api.auth import sign_profile_cookie_value, verify_profile_cookie_value
session_cookie = 'session-token.session-sig'
monkeypatch.setattr('api.auth.verify_session', lambda cookie: True)
signed_profile = sign_profile_cookie_value('alice', session_cookie)
monkeypatch.setattr('api.auth.verify_session', lambda cookie: False)
assert verify_profile_cookie_value(signed_profile, session_cookie) is None
def test_build_profile_cookie_fails_closed_when_auth_session_missing(self, monkeypatch, caplog):
from api.helpers import build_profile_cookie
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
monkeypatch.setattr('api.auth.verify_session', lambda cookie: False)
handler = MagicMock()
handler.headers.get = lambda k, d='': ''
with pytest.raises(RuntimeError):
build_profile_cookie('alice', handler)
assert 'Failed to sign active profile cookie' in caplog.text
def test_get_profile_cookie_accepts_default(self, monkeypatch):
from api.helpers import get_profile_cookie
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
handler = MagicMock()
handler.headers.get = lambda k, d='': 'hermes_profile=default' if k == 'Cookie' else d
assert get_profile_cookie(handler) == 'default'
def test_get_profile_cookie_rejects_injection(self):
def test_get_profile_cookie_rejects_injection(self, monkeypatch):
"""Cookie value must pass _PROFILE_ID_RE fullmatch — rejects traversal/injection."""
from api.helpers import get_profile_cookie
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
for bad in ('../etc', 'a/b', 'name;DROP', 'WithCaps', 'has space', '.hidden'):
handler = MagicMock()
handler.headers.get = lambda k, d='', v=bad: f'hermes_profile={v}' if k == 'Cookie' else d
@@ -85,6 +172,7 @@ class TestProfileCookieHelpers:
from api.helpers import build_profile_cookie, get_profile_cookie
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_social')
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
s = build_profile_cookie('writer')
assert 'hermes_profile_social=writer' in s
@@ -96,10 +184,46 @@ class TestProfileCookieHelpers:
)
assert get_profile_cookie(handler) == 'writer'
def test_verify_profile_cookie_rejects_invalid_name_pattern(self, monkeypatch):
"""Defense-in-depth (#4023 Opus hardening): even a correctly-HMAC-signed
cookie whose profile name fails _PROFILE_ID_RE must be rejected by the
verifier itself, so a future caller can't skip the pattern gate."""
from api.auth import sign_profile_cookie_value, verify_profile_cookie_value
session_cookie = 'session-token.session-sig'
monkeypatch.setattr('api.auth.verify_session', lambda cookie: cookie == session_cookie)
# Sign a hostile name (would never come from a real switch, but proves the
# verifier validates the name even when the signature is valid).
signed = sign_profile_cookie_value('../etc', session_cookie)
assert verify_profile_cookie_value(signed, session_cookie) is None
# And a normal name still round-trips.
ok = sign_profile_cookie_value('alice', session_cookie)
assert verify_profile_cookie_value(ok, session_cookie) == 'alice'
def test_build_profile_cookie_requires_handler_when_auth_enabled(self, monkeypatch):
"""Defense-in-depth (#4023 Opus hardening): a future call site that forgets
to pass the handler while auth is enabled must NOT silently emit an
unsigned profile cookie — it raises instead."""
from api.helpers import build_profile_cookie
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: True)
with pytest.raises(RuntimeError):
build_profile_cookie('alice') # no handler
def test_build_profile_cookie_allows_no_handler_when_auth_disabled(self, monkeypatch):
"""No-auth mode keeps the legacy plain-name cookie with no handler."""
from api.helpers import build_profile_cookie
monkeypatch.delenv('WEBUI_PROFILE_COOKIE_NAME', raising=False)
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
s = build_profile_cookie('alice')
assert 'hermes_profile=alice' in s
def test_configured_profile_cookie_ignores_default_cookie_name(self, monkeypatch):
from api.helpers import get_profile_cookie
monkeypatch.setenv('WEBUI_PROFILE_COOKIE_NAME', 'hermes_profile_main')
monkeypatch.setattr('api.auth.is_auth_enabled', lambda: False)
handler = MagicMock()
handler.headers.get = lambda k, d='': 'hermes_profile=social_profile' if k == 'Cookie' else d