From 4dca506858651d5424842f166b47f7ca40b031c7 Mon Sep 17 00:00:00 2001 From: hinotoi-agent Date: Fri, 12 Jun 2026 09:52:23 +0800 Subject: [PATCH 1/3] Bind profile cookie to auth session --- api/auth.py | 41 ++++++++++++++++++ api/helpers.py | 50 +++++++++++++++++----- api/routes.py | 2 +- tests/test_issue803.py | 95 ++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 174 insertions(+), 14 deletions(-) diff --git a/api/auth.py b/api/auth.py index 7960e954..4cd5de82 100644 --- a/api/auth.py +++ b/api/auth.py @@ -467,6 +467,47 @@ 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=`` 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 + 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. diff --git a/api/helpers.py b/api/helpers.py index 4a39ef9e..af47893a 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -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,16 @@ 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 + 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' diff --git a/api/routes.py b/api/routes.py index 23a5f6dd..3b7e5086 100644 --- a/api/routes.py +++ b/api/routes.py @@ -8320,7 +8320,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) diff --git a/tests/test_issue803.py b/tests/test_issue803.py index e1a2698e..ca2e2a29 100644 --- a/tests/test_issue803.py +++ b/tests/test_issue803.py @@ -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 @@ -100,6 +188,7 @@ class TestProfileCookieHelpers: 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 From 03799f8e4a19f92833d80ab765da6618912de420 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 12 Jun 2026 07:37:48 +0000 Subject: [PATCH 2/3] =?UTF-8?q?harden(#4023):=20apply=20Opus=20security=20?= =?UTF-8?q?findings=20=E2=80=94=20verify-side=20name-pattern=20gate=20+=20?= =?UTF-8?q?require=20handler=20when=20auth=20enabled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus independent security review concurred SAFE and surfaced 2 LOW defense-in-depth items, both applied: (1) verify_profile_cookie_value now validates the profile name against _PROFILE_ID_RE itself (not only in get_profile_cookie) so a future second caller can't return an unvalidated name; (2) build_profile_cookie raises when auth is enabled and no handler is passed, so a future call site can't silently emit an unsigned (session-unbound) profile cookie. +3 regression tests. --- api/auth.py | 6 ++++++ api/helpers.py | 11 +++++++++++ tests/test_issue803.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/api/auth.py b/api/auth.py index 4cd5de82..8dd05de6 100644 --- a/api/auth.py +++ b/api/auth.py @@ -498,6 +498,12 @@ def verify_profile_cookie_value(cookie_value: str, session_cookie_value: str | N 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(), diff --git a/api/helpers.py b/api/helpers.py index af47893a..544eb811 100644 --- a/api/helpers.py +++ b/api/helpers.py @@ -561,6 +561,17 @@ def build_profile_cookie(name: str, handler=None) -> str: cookie = _hc.SimpleCookie() cookie_name = get_profile_cookie_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 diff --git a/tests/test_issue803.py b/tests/test_issue803.py index ca2e2a29..34a8a544 100644 --- a/tests/test_issue803.py +++ b/tests/test_issue803.py @@ -184,6 +184,41 @@ 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 From 061eda9b917aec195e851e1c43e5b7bc739c43f1 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Fri, 12 Jun 2026 07:43:46 +0000 Subject: [PATCH 3/3] docs(changelog): stamp #4023 profile-cookie session binding as v0.51.368 (Release MG) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c807b4..545139c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` 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