[security] fix(session): scope detail loads to active profile#3982
[security] fix(session): scope detail loads to active profile#3982Hinotoi-agent wants to merge 6 commits into
Conversation
|
| Filename | Overview |
|---|---|
| api/routes.py | Adds _session_visible_to_active_profile guard in both the WebUI sidecar path (after get_session) and the CLI fallback path (after _lookup_cli_session_metadata). Import of get_active_profile_name moved to module level. The handler is None short-circuit is documented as intentional for unit callers; no production call sites exist. |
| tests/test_issue1611_session_profile_filtering.py | Adds four new regression tests covering: full transcript, metadata-only, cookie-less, and CLI fallback paths. All patch api.routes._get_active_profile_name (the correct module-level target). Fakery wires bad and j into a captured dict to detect which response path fires. |
| tests/test_issue2762_state_sync_profile_kwarg.py | Adds a _get_active_profile_name monkeypatch so the new profile guard doesn't 404 a same-profile session in the existing metadata-only test. |
| tests/test_webui_state_db_reconciliation.py | Same one-line fix as test_issue2762: patches _get_active_profile_name to match the session's profile so the reconciliation test continues to exercise the intended code path. |
Sequence Diagram
sequenceDiagram
participant Client
participant Route as /api/session handler
participant ProfileCheck as _session_visible_to_active_profile
participant GetSession as get_session()
participant CLIMeta as _lookup_cli_session_metadata()
Client->>Route: "GET /api/session?session_id=X"
alt WebUI sidecar path
Route->>GetSession: get_session(sid)
GetSession-->>Route: session object
Route->>ProfileCheck: _session_visible_to_active_profile(session.profile, handler)
ProfileCheck->>ProfileCheck: _get_active_profile_name()
ProfileCheck->>ProfileCheck: _profiles_match(session_profile, active_profile)
alt profiles match
ProfileCheck-->>Route: True
Route-->>Client: 200 session payload
else profiles differ
ProfileCheck-->>Route: False
Route-->>Client: 404 Session not found
end
else KeyError — CLI fallback path
GetSession-->>Route: KeyError
Route->>Route: check SESSION_INDEX_FILE for webui origin
alt was WebUI session (deleted sidecar)
Route-->>Client: 404 Session not found
else genuine CLI session
Route->>CLIMeta: _lookup_cli_session_metadata(sid)
CLIMeta-->>Route: cli_meta (may include profile field)
Route->>ProfileCheck: _session_visible_to_active_profile(cli_meta.profile, handler)
alt profiles match
ProfileCheck-->>Route: True
Route-->>Client: 200 CLI stub payload
else profiles differ
ProfileCheck-->>Route: False
Route-->>Client: 404 Session not found
end
end
end
Reviews (6): Last reviewed commit: "Merge branch 'master' into security/scop..." | Re-trigger Greptile
|
Thanks for the review. Addressed in 3c1161f:
Validation: python -m pytest tests/test_issue1611_session_profile_filtering.py tests/test_session_tail_payload.py tests/test_session_metadata_cli_lookup.py -q
# 19 passed, 1 warning |
SummaryI pulled the branch and read both hunks in Code referenceMain-path check (routes.py:5807 on the branch): s = get_session(sid, metadata_only=(not load_messages))
_session_profile = getattr(s, 'profile', None) or None
_active_profile = _get_active_profile_name()
if (
load_messages
and isinstance(_session_profile, str)
and _session_profile.strip()
and not _profiles_match(_session_profile, _active_profile)
):
return bad(handler, "Session not found", 404)CLI-fallback check (routes.py:6112 on the branch): cli_meta = _lookup_cli_session_metadata(sid)
_session_profile = (cli_meta or {}).get("profile") or None
_active_profile = _get_active_profile_name()
if not _profiles_match(_session_profile, _active_profile):
return bad(handler, "Session not found", 404)Three concerns1. The two checks gate differently. The main path only 404s when 2. The metadata-only exemption interacts with the "Show from other profiles" flow. 3. The Test noteThe two added tests ( VerdictSound and worth landing once the two checks are unified and the |
|
Thanks — addressed in 7fb0bab. Validation:
|
…s to active profile (#3982, #3991) (#4269) * stage-3982-3991: [security] scope session detail-reads + exports to active profile (paired, re-cut onto v0.51.442, conflict-resolved properly) * Release PD: [security] scope session by-id reads + exports to active profile (#3982, #3991) Paired Hinotoi-agent security PRs, re-cut onto v0.51.442 (avoided the stale-base revert of the #4171 passkey gate that Codex caught). Both /api/session and /api/session/export now apply _profiles_match→404. Codex SAFE + Opus SHIP + 49 tests green; passkey gate verified intact. Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com> --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
…ll-profiles list (#4067) (#4296) * Release PL (v0.51.451): show named-profile external sessions in the all-profiles list (#4067, #4065) All-profiles session enumeration now scans across profiles only when the request explicitly asks (all-profiles view); cross-profile import resolves to the active profile's state.db for unqualified requests (foreign id -> 404), mirroring the #3982/#3991 detail/export profile-scoping gate. Co-authored-by: rodboev * gate fix (Codex CORE + Opus): close cross-profile leak on the import_cli refresh/existing-session branch The already-imported branch of _handle_session_import_cli forced allow_all_profiles=True whenever the globally-stored session carried a profile, with no active-profile visibility gate — so an unqualified request from profile A could read/refresh a session imported under profile B's live state.db (both advisors reproduced the foreign-transcript leak). Now gated exactly like the /api/session detail + export endpoints: unqualified requests require _session_visible_to_active_profile; explicit all_profiles requires a matching profile. + 3 regression tests. * gate fix (Codex SILENT, re-gate): thread source_filter into the all-profiles CLI scan (models.py:4113) The all-profiles branch keys its cache on source_filter but called _load_cli_sessions_uncached without it, so /api/sessions?all_profiles=1 with a source filter returned every-source sessions under a filtered key. + regression test. --------- Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Summary
This PR hardens the profile boundary for direct session-detail reads.
/api/session?session_id=...loaded a session by id without checking whether the loaded session belongs to the request's active Hermes profile._profiles_match(...)semantics that already scope/api/sessions, including legacy/default-root alias behavior.404instead of a transcript payload.Security issues covered
/api/sessioneven though/api/sessionshides that row by default.Before this PR
/api/sessionsfiltered rows to the active profile by default./api/sessionthen accepted a rawsession_id, calledget_session(...), and serialized the loaded session without applying the same active-profile check.After this PR
/api/sessionderives the loaded session'sprofile, resolves the active request profile, and returns404when they do not match._profiles_match(...)so existing legacy/root-profile compatibility remains intact.Why this matters
Hermes profiles separate config, state, skills, memory, cron, and API-related data. The session list already treated profile scoping as a boundary, but the detail endpoint could bypass that boundary when the caller knew or retained a session id. Returning
404keeps direct session loads aligned with the list endpoint and avoids exposing another profile's transcript.Attack flow
Affected code
api/routes.py,tests/test_issue1611_session_profile_filtering.pyRoot cause
Issue: direct session-detail reads were not profile-scoped
/api/sessiondetail path relied onget_session(sid, ...)and only later merged/redacted the loaded session./api/sessionsalready applies before showing sessions to the client.CVSS assessment
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NRationale:
Safe reproduction steps
profile="other".default.GET /api/session?session_id=<foreign>&messages=1&resolve_model=0.sessionpayload for the foreign profile.404and no session payload.Expected vulnerable behavior
/api/sessionsprofile scoping.200with the foreign-profile session payload.404path.Changes in this PR
/api/sessionloads the requested session.404for mismatched profiles to avoid confirming cross-profile session existence.Files changed
api/routes.py/api/sessiondetail loads to the active profile before serializing session data.tests/test_issue1611_session_profile_filtering.py404and no JSON transcript payload.Maintainer impact
/api/session._profiles_match(...)helper.Fix rationale
session_idinto transcript data._profiles_match(...)avoids duplicating profile semantics and preserves compatibility with renamed root profiles and legacy rows.Type of change
Test plan
/api/sessionpayload tests pass.Executed with:
python -m pytest tests/test_issue1611_session_profile_filtering.py::test_get_session_rejects_session_from_inactive_profile -qpython -m pytest tests/test_issue1611_session_profile_filtering.py::test_get_session_rejects_session_from_inactive_profile tests/test_issue1611_session_profile_filtering.py tests/test_session_tail_payload.py tests/test_session_metadata_cli_lookup.py -qDisclosure notes