Skip to content

[security] fix(auth): bind active profile cookie to session#4023

Merged
1 commit merged into
nesquena:masterfrom
Hinotoi-agent:fix-profile-cookie-session-binding
Jun 12, 2026
Merged

[security] fix(auth): bind active profile cookie to session#4023
1 commit merged into
nesquena:masterfrom
Hinotoi-agent:fix-profile-cookie-session-binding

Conversation

@Hinotoi-agent

@Hinotoi-agent Hinotoi-agent commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Thinking Path

  • PR [security][DRAFT] Scope session-by-id endpoints to active profile (absorbs #3982 + #3991) #3999 scopes session-by-id behavior to the active profile.
  • That guard is only effective if the active profile is derived from trusted state.
  • The current request profile was still accepted from a bare hermes_profile cookie value.
  • This PR keeps the active-profile UX cookie, but authenticates it against the current WebUI auth session before it can influence profile-scoped authorization.
  • The result is a narrow defense-in-depth fix for profile isolation without adding frontend infrastructure or changing the no-auth local preference behavior.

Summary

This PR hardens the active-profile trust boundary used by the profile-scoped session guards added in #3999.

  • Binds hermes_profile to the current hermes_session when WebUI auth is enabled.
  • Rejects unsigned or cross-session profile cookies before they can set request-local profile state.
  • Keeps the legacy plain profile-name cookie behavior for no-auth deployments, where the cookie is only a per-browser UI preference.
  • Adds regression coverage for forged, valid, and wrong-session profile cookie values.

Security issues covered

Issue Impact Severity
Client-controlled active profile cookie can influence profile-scoped authorization An authenticated client could forge hermes_profile=<target-profile> and make guarded session/file operations evaluate under another profile High if profiles isolate users, tenants, workspaces, sessions, secrets, or API-key-backed state; Medium/hardening if profiles are only a local trusted-user convenience

Before this PR

  • The server accepted the active profile from the hermes_profile request cookie.
  • get_profile_cookie() validated the value syntactically, but did not authenticate it or bind it to the logged-in WebUI session.
  • Profile visibility guards could therefore rely on an attacker-selectable active profile.
  • Tests covered plain cookie parsing, but not forged authenticated-session profile selection.

After this PR

  • Auth-enabled deployments require the profile cookie value to be signed for the current hermes_session token.
  • Unsigned profile cookies are ignored when auth is enabled.
  • Profile cookies signed for another session are ignored when auth is enabled.
  • /api/profile/switch emits the session-bound profile cookie format when auth is enabled.
  • No-auth deployments keep the existing plain cookie behavior for local UI preference compatibility.

Why this matters

Profiles are used as a security-sensitive boundary by the new session visibility checks. If the server trusts a raw browser cookie as the authorization input for that boundary, any authenticated client can choose the profile that the guard evaluates against.

That weakens the intended isolation added by #3999: the guard may be present and called correctly, but it can still be evaluated under attacker-supplied profile state.

How this differs from related issue/PR

This is related to #3999, but it is not the same issue as “session-by-id endpoints missing active-profile guards.”

  • [security][DRAFT] Scope session-by-id endpoints to active profile (absorbs #3982 + #3991) #3999 adds or centralizes the active-profile checks on session-by-id paths.
  • This PR hardens the trust source used by those checks.
  • The failure mode here is not a missing guard call site; it is that a correctly called guard can be bypassed when its active-profile input comes from an unauthenticated, client-controlled cookie.
  • Both layers matter: endpoints need the guard, and the guard needs trusted profile state.

Attack flow

Authenticated WebUI client
    -> sends a valid auth session cookie
        -> forges Cookie: hermes_profile=<target-profile>
            -> request-local active profile is set from the forged value
                -> profile-scoped session/file guard evaluates under attacker-selected profile
                    -> cross-profile session/file access may be allowed

Affected code

Issue Files
Client-controlled active profile cookie can influence authorization server.py, api/helpers.py, api/routes.py, api/auth.py

Root cause

Issue: client-controlled active profile cookie can influence profile-scoped authorization

  • server.py sets request-local profile state from get_profile_cookie(handler) before route handling.
  • get_profile_cookie() previously accepted any syntactically valid profile name from the hermes_profile cookie.
  • api/routes.py profile visibility checks compare target session metadata against the request-local active profile.
  • That made a UI preference cookie part of an authorization decision without authenticating it against the active WebUI session.

CVSS assessment

Issue CVSS v3.1 Vector
Client-controlled active profile cookie can influence profile-scoped authorization 8.1 High CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Rationale:

  • The attacker must already be authenticated to WebUI.
  • The exploit is network-reachable in WebUI deployments and does not require user interaction.
  • If profiles isolate sensitive sessions, files, API-backed state, or workspace data, the impact is cross-profile confidentiality and integrity loss.
  • If a deployment treats all profiles as the same fully trusted local user, the practical severity is lower and this becomes defense-in-depth hardening.

Safe reproduction steps

  1. Enable WebUI auth and create or use two profiles, for example alice and bob.

  2. Create or identify a session/file operation that should only be visible while bob is the active authorized profile.

  3. Authenticate as a WebUI client with a valid hermes_session.

  4. On vulnerable code, send the guarded request with a forged plain profile cookie:

    Cookie: hermes_session=<valid-session>; hermes_profile=bob
  5. Observe that the active-profile guard can evaluate the request under bob because the cookie value is accepted as request-local profile state.

  6. With this PR, the same unsigned hermes_profile=bob value is ignored when auth is enabled, and a value signed for a different session is also ignored.

Expected vulnerable behavior

  • A bare client-controlled hermes_profile value should never determine the profile used for authorization.
  • Pre-patch auth-enabled requests could still supply a plain profile name in the cookie.
  • The safe proof signal is that unsigned and cross-session profile cookies now return None from get_profile_cookie() when auth is enabled.

Changes in this PR

  • Adds sign_profile_cookie_value() and verify_profile_cookie_value() helpers in api/auth.py.
  • Binds the profile cookie signature to the raw authenticated session token.
  • Updates get_profile_cookie() to require a valid session-bound signature when auth is enabled.
  • Preserves existing plain profile cookie parsing for no-auth deployments.
  • Updates build_profile_cookie() and /api/profile/switch so profile switches emit the session-bound cookie in auth-enabled deployments.
  • Adds regression tests for valid, unsigned, and wrong-session profile cookies.

Files changed

Category Files What changed
Auth helpers api/auth.py Added session-bound signing and verification helpers for profile cookie values
Cookie parsing api/helpers.py Authenticates hermes_profile before returning it when auth is enabled; preserves no-auth legacy behavior
Profile switch route api/routes.py Passes the handler into build_profile_cookie() so the Set-Cookie value can be bound to the current session
Regression tests tests/test_issue803.py Covers valid signed profile cookie, unsigned forgery rejection, wrong-session rejection, and emitted signed cookie behavior

Maintainer impact

  • The patch is intentionally narrow and limited to active-profile cookie trust.
  • It does not add new dependencies, frontend build steps, or new storage services.
  • No-auth deployments keep the existing plain profile-name cookie behavior.
  • Auth-enabled deployments may require users to switch profiles again once after the patch if their browser only has the old unsigned profile cookie.
  • The implementation keeps the existing HttpOnly, SameSite=Lax, path-scoped cookie shape.

Fix rationale

The active profile can influence which profile-scoped sessions and files are visible. In auth-enabled deployments, that makes it authorization-relevant state. Binding the profile cookie to the authenticated WebUI session keeps the UI preference mechanism while preventing clients from supplying arbitrary profile names across sessions.

The fix is narrow because it changes only the cookie trust boundary and the profile-switch Set-Cookie path. The regression tests lock the security behavior at the helper boundary where request profile state is derived.

Type of change

  • Security fix
  • Tests
  • Documentation update
  • Refactor with no behavior change

Test plan

  • Syntax check for touched server modules.
  • Focused profile/session regression suite.
  • git diff --check.
  • Full pytest tests/ -v --timeout=60 was not run locally; this PR ran the focused profile/session tests that cover the changed code paths.

Executed with:

  • python3 -m py_compile api/auth.py api/helpers.py api/routes.py
  • python3 -m pytest tests/test_issue803.py tests/test_session_ops.py tests/test_issue1611_session_profile_filtering.py tests/test_profile_switch_ux.py tests/test_profile_switch_1200.py -q
  • git diff --check

Result:

  • 101 passed, 1 warning

Risks / Follow-ups

  • Existing auth-enabled browser sessions with an old unsigned hermes_profile cookie may fall back to the default/requested server profile until the user switches profiles again.
  • This PR does not attempt to redesign profile authorization or add per-user profile ACLs; it only prevents the active-profile guard from trusting an unauthenticated cookie value.
  • If profiles are intended to be a stronger multi-user boundary, a follow-up could explicitly bind allowed profile IDs to server-side user/session state.

Contract Routing

This is a security-sensitive behavior fix in the server/profile-cookie path. It does not intentionally change a documented public contract, RFC, UI flow, or product-semantics document. Evidence used: focused helper and session/profile tests listed in the test plan.

Model Used

AI-assisted implementation and PR drafting via Hermes Agent using OpenAI GPT-5.5. The final code changes were validated locally with the commands listed above.

Disclosure notes

  • This PR is bounded to the active-profile cookie trust source used by profile-scoped session/file authorization.
  • It does not claim unauthenticated reachability; the attacker condition is an already authenticated WebUI client.
  • It does not include production testing or real secrets.
  • No unrelated files were changed.

@greptile-apps

greptile-apps Bot commented Jun 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR binds the hermes_profile cookie to the current hermes_session token via HMAC-SHA256 when WebUI auth is enabled, preventing authenticated clients from forging the active-profile cookie to bypass profile-scoped session and file visibility guards. It also bundles several unrelated fixes from v0.51.360–365 (configurable auth cookie name, SSE idle-tab visibility, session self-heal, lineage-click fix, reasoning-effort context, CLI session materialization, malformed providers handling).

  • Security fix (api/auth.py, api/helpers.py, api/routes.py): Adds sign_profile_cookie_value/verify_profile_cookie_value helpers; get_profile_cookie now calls verify_profile_cookie_value (which validates the session via verify_session) in auth-enabled deployments; build_profile_cookie raises RuntimeError on signing failure instead of silently emitting an unsigned value; the /api/profile/switch route now passes handler so the signed cookie can be emitted.
  • Auth cookie name configurability (api/auth.py): Adds _resolve_cookie_name() reading HERMES_WEBUI_COOKIE_NAME, wired into parse_cookie, set_auth_cookie, and clear_auth_cookie, with RFC 6265 token validation and a warning fallback.
  • Frontend and backend fixes (static JS, api/config.py, api/routes.py): SSE Page Visibility hooks close idle streams on hidden tabs and correctly reopen them; session self-heal clears stale boot-time IDs; lineage-segment clicks skip collapsed-ID resolution; reasoning POST now carries session model context; malformed non-dict providers config is treated as unconfigured.

Confidence Score: 5/5

The security fix is correct and well-tested; the only issues found are non-blocking: dead code in the new _get_or_materialize_session helper and the PR bundling multiple independent changes.

The core HMAC scheme is sound: profile names cannot contain dots, so rsplit reliably separates the name from its SHA-256 signature. Both signing and verification gate on verify_session() before any crypto, correctly rejecting expired or revoked sessions. build_profile_cookie raises RuntimeError on signing failure instead of silently emitting an unsigned value. The regression suite covers valid signed cookie, unsigned forgery, cross-session forgery, expired session, and fail-closed behavior.

api/routes.py — the dead messaging-session materialization block in _get_or_materialize_session is unreachable but leaves a misleading comment worth cleaning up.

Important Files Changed

Filename Overview
api/auth.py Adds sign_profile_cookie_value and verify_profile_cookie_value helpers; both call verify_session() before proceeding, so expired/revoked sessions are correctly rejected. Also adds _resolve_cookie_name() for configurable auth cookie name. Core HMAC scheme is sound: profile names cannot contain dots, so rsplit('.', 1) reliably separates the name from its SHA-256 signature.
api/helpers.py Updates get_profile_cookie to call verify_profile_cookie_value when auth is enabled, and build_profile_cookie to raise RuntimeError on signing failure instead of silently returning an unsigned value. Exception handlers now log with exc_info=True.
api/routes.py Adds _get_or_materialize_session and updates the three mutation endpoints; wires handler into build_profile_cookie on the profile switch path. The messaging-session creation block is unreachable dead code.
api/config.py Introduces _get_providers_cfg() and _get_provider_cfg() helpers that guard against non-dict providers values, and threads model context into set_reasoning_effort.
tests/test_issue803.py Adds comprehensive regression coverage for the profile cookie security fix: valid signed cookie, unsigned forgery rejection, wrong-session rejection, emitted signed cookie verification, and fail-closed behavior.

Reviews (3): Last reviewed commit: "Bind profile cookie to auth session" | Re-trigger Greptile

Comment thread api/auth.py
Comment thread api/auth.py
Comment thread api/helpers.py
@Hinotoi-agent Hinotoi-agent changed the title Bind active profile cookie to auth session [security] fix(auth): bind active profile cookie to session Jun 12, 2026
@Hinotoi-agent Hinotoi-agent force-pushed the fix-profile-cookie-session-binding branch from 406c918 to 194d891 Compare June 12, 2026 02:09
@Hinotoi-agent

Copy link
Copy Markdown
Contributor Author

Addressed the Greptile review feedback in 194d891c:

  • verify_profile_cookie_value() now requires verify_session(session_cookie_value) before trusting a profile-cookie HMAC, so expired/revoked sessions cannot keep a profile binding alive on unauthenticated paths such as /api/csp-report.
  • sign_profile_cookie_value() now fails closed when there is no active auth session instead of returning an unsigned bare profile name.
  • build_profile_cookie() no longer silently falls back to an unsigned cookie in auth mode; it logs the signing failure and raises so /api/profile/switch does not return a misleading 200 with a cookie that will be rejected on the next request.
  • get_profile_cookie() now logs unexpected profile-cookie verification exceptions instead of swallowing crypto/config failures silently.
  • Added regressions for expired-session rejection and missing-session fail-closed behavior.

Validation:

/Users/lennon/.hermes/hermes-agent/venv/bin/python -m py_compile api/auth.py api/helpers.py api/routes.py
git diff --check
/Users/lennon/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_issue803.py tests/test_session_ops.py tests/test_issue1611_session_profile_filtering.py tests/test_profile_switch_ux.py tests/test_profile_switch_1200.py -q

104 passed, 1 warning

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Reviewed the diff at origin/master...HEAD. The cookie-trust core is sound, but the PR is much broader than its "narrow and limited to active-profile cookie trust" framing — it bundles a full session-by-id guard rollout that overlaps two other open security PRs. Flagging that before it creates a merge tangle.

The cookie-binding core is correct ✅

api/auth.py adds sign_profile_cookie_value / verify_profile_cookie_value that HMAC the profile name to the authenticated session token:

sig = hmac.new(_signing_key(), f"profile:{token}:{profile_name}".encode(), hashlib.sha256).hexdigest()
return f"{profile_name}.{sig}"

I verified every helper this leans on already exists on master: _signing_key() (auth.py:257), verify_session() (438), _session_token_from_cookie_value() (462), parse_cookie() (503), is_auth_enabled() (381), and the hmac/hashlib imports (6-7). Verification uses hmac.compare_digest, so it's constant-time. api/helpers.py:get_profile_cookie correctly gates on is_auth_enabled() — signed-and-verified when auth is on, legacy plain _PROFILE_ID_RE name when off — which preserves the no-auth UI-preference behavior. The build_profile_cookie(name, handler=None) raise-on-sign-failure is cleanly caught by the except RuntimeError -> 409 arm at the /api/profile/switch route (routes.py:8329), so a missing-session edge can't 500. This half is a clean, well-scoped defense-in-depth fix.

The scope concern

The PR body says it's "intentionally narrow and limited to active-profile cookie trust," but api/routes.py carries ~50 hunks that add a brand-new _session_visible_to_active_profile() / _visible_session_for_file_ops() guard and wire it into session-detail GETs, file/workspace ops, compress, git, import, media, and more — plus three matching guards in api/upload.py:

def _session_visible_to_active_profile(session_profile, handler=None) -> bool:
    if handler is None: return True
    if getattr(handler, "_hermes_internal_trusted", False): return True
    active_profile = _get_active_profile_name()
    ...
    return _profiles_match(session_profile, active_profile)

That is effectively the entire fix for issue #4000 ("session-by-id endpoints don't enforce the active-profile boundary"), not just cookie hardening. And it collides directly with two already-open security PRs:

All three edit the same routes file and the same test_issue1611_session_profile_filtering.py (which this PR grows by 733 lines). Whichever lands first will force a non-trivial rebase on the other two, and the guard semantics need to be reconciled in one place rather than three.

Recommendation

Split this PR. Keep the cookie-trust core here (api/auth.py + the helpers.py get_profile_cookie/build_profile_cookie changes + the tests/test_issue803.py additions) — that's the genuinely new, narrow, reviewable security boundary and it's good. Move the _session_visible_to_active_profile route/upload rollout into the #4000 track and reconcile it with #3982/#3991 so there's a single guard definition. As the PR body itself argues, "endpoints need the guard, and the guard needs trusted profile state" — those are two layers, and they'd be far easier to review and land as two PRs.

Per repo policy I read the tests rather than running them, so the 101 passed figure in the body isn't something I re-verified here; my concern is structural (overlap/merge-order), not correctness of the cookie logic, which looks right.

@Hinotoi-agent Hinotoi-agent force-pushed the fix-profile-cookie-session-binding branch from 194d891 to 4dca506 Compare June 12, 2026 02:52
@Hinotoi-agent Hinotoi-agent changed the base branch from stage-lx-security to master June 12, 2026 02:53
@Hinotoi-agent

Copy link
Copy Markdown
Contributor Author

Addressed the split recommendation in 4dca5068.

What changed:

Current PR diff file list:

api/auth.py
api/helpers.py
api/routes.py
tests/test_issue803.py

Validation rerun on the master-based branch:

/Users/lennon/.hermes/hermes-agent/venv/bin/python -m py_compile api/auth.py api/helpers.py api/routes.py
/Users/lennon/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_issue803.py tests/test_session_ops.py tests/test_profile_switch_ux.py tests/test_profile_switch_1200.py -q
# 68 passed, 1 warning

/Users/lennon/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_issue1611_session_profile_filtering.py -q
# 11 passed, 1 warning

/Users/lennon/.hermes/hermes-agent/venv/bin/python -m pytest tests/test_issue3623_profile_visibility.py -q
# 4 passed, 1 warning

git diff --check

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Re-fetched after the force-push (4dca5068) and confirmed the split landed exactly as recommended. The diff is now the four cookie-trust files only — the _session_visible_to_active_profile route/upload rollout is gone:

api/auth.py            | 41 +++
api/helpers.py         | 50 ++++--
api/routes.py          |  2 +-
tests/test_issue803.py | 95 +++++++++++--

The single api/routes.py hunk is now just the call-site update at the /api/profile/switch handler (routes.py:8323), build_profile_cookie(name)build_profile_cookie(name, handler) — so the route can emit the session-bound cookie. That's correct and minimal.

Cookie-trust core re-verified

get_profile_cookie (api/helpers.py:524) now branches on is_auth_enabled():

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
...
# 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

This is the right shape: auth-on requires a session-bound signature; auth-off keeps the legacy plain _PROFILE_ID_RE name as a UI preference. The _valid_profile_name re-check on the verified value is a good belt-and-suspenders step.

The verify_profile_cookie_value rsplit('.', 1) (api/auth.py:495) is safe because _PROFILE_ID_RE = ^[a-z0-9][a-z0-9_-]{0,63}$ (api/profiles.py:29) forbids dots in profile names — so the name and the hex signature can't be confused. Verification gates on verify_session() before the HMAC and uses hmac.compare_digest, so it's both fail-closed on dead sessions and constant-time. build_profile_cookie raising RuntimeError on sign failure (helpers.py:567) is cleanly caught by the except RuntimeError arm at the switch route, so a missing-session edge returns a controlled error rather than a 500 or a silently-unsigned cookie.

Net: this is now the narrow, reviewable security boundary the PR body claimed, and the session-by-id guard work is correctly parked for the #4000 / #3982 / #3991 track. My structural concern from the last pass is resolved — clearing it on my end. Per repo policy I read the tests rather than running them, so the 68 passed / 11 passed figures aren't something I re-executed; the change is correct on read.

nesquena-hermes pushed a commit that referenced this pull request Jun 12, 2026
…n gate + require handler when auth enabled

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.
nesquena-hermes pushed a commit that referenced this pull request Jun 12, 2026
@nesquena-hermes nesquena-hermes closed this pull request by merging all changes into nesquena:master in 9e96f5f Jun 12, 2026
merodahero pushed a commit to merodahero/hermes-webui that referenced this pull request Jun 13, 2026
merodahero pushed a commit to merodahero/hermes-webui that referenced this pull request Jun 13, 2026
…e-pattern gate + require handler when auth enabled

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.
merodahero pushed a commit to merodahero/hermes-webui that referenced this pull request Jun 13, 2026
merodahero pushed a commit to merodahero/hermes-webui that referenced this pull request Jun 13, 2026
Release MG — v0.51.368 — bind active-profile cookie to auth session (nesquena#4023, fixes nesquena#803)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants