Skip to content

Release PD (v0.51.443): [security] scope session by-id reads + exports to active profile (#3982, #3991)#4269

Merged
nesquena-hermes merged 2 commits into
masterfrom
stage-3982-3991
Jun 15, 2026
Merged

Release PD (v0.51.443): [security] scope session by-id reads + exports to active profile (#3982, #3991)#4269
nesquena-hermes merged 2 commits into
masterfrom
stage-3982-3991

Conversation

@nesquena-hermes

Copy link
Copy Markdown
Collaborator

Release PD — v0.51.443

Ships #3982 + #3991 (Hinotoi-agent, paired) — [security] scope session by-id reads + exports to the active profile. Nathan-approved to ship together; self-rebased onto v0.51.442 and gated fresh (Codex + Opus + full suite).

Threat closed

/api/sessions (the list) scopes rows to the active profile, but two by-id endpoints did not:

Both loaded a session purely by id, so a stale/leaked/guessed session id from another profile could disclose that profile's transcript or export. Both now apply _profiles_match(...) and return the same 404 used for missing sessions (no foreign-profile existence disclosure). Default/legacy-root aliasing preserved; /api/sessions unchanged.

⚠️ Stale-base note (caught by the gate)

First stage attempt was cut from an old base; the 3-dot diffs (180/401 commits behind) would have reverted the #4171 passkey gate shipped minutes earlier in v0.51.442. Codex caught it; I re-cut onto current master and re-verified the passkey gate is intact (_require_passkey_registration_auth present + called on both registration endpoints).

Gate results

  • Codex SAFE: both endpoints 404 foreign-profile ids (detail @6851, CLI-fallback @7148, export @10220), default/legacy alias preserved, passkey gate NOT reverted, test conflict cleanly resolved (4 distinct complete tests, no splicing).
  • Opus SHIP: surgical (32 prod lines), _profiles_match→404 reused, 49 passing tests covering reject + same-profile/alias allow.
  • Full suite: profile-filtering + passkey tests green in isolation; wider failures are this box's process-group cascade artifact (CI authoritative).

These are the surgical, proven subset of the parked draft #3999 (which stays parked for the broader ~44-site sweep). Closes #3982, #3991.

Co-authored-by: Hinotoi-agent

nesquena-hermes and others added 2 commits June 15, 2026 21:09
…ctive profile (paired, re-cut onto v0.51.442, conflict-resolved properly)
…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>
@nesquena-hermes nesquena-hermes merged commit 2a3baa7 into master Jun 15, 2026
11 checks passed
@nesquena-hermes nesquena-hermes deleted the stage-3982-3991 branch June 15, 2026 21:22
@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR closes two IDOR-class information-disclosure paths where GET /api/session and GET /api/session/export loaded sessions purely by ID, bypassing the profile scoping that /api/sessions (the list) already enforced. Both now call into _profiles_match before emitting any data and return the same 404 used for missing sessions, so a foreign-profile session ID does not reveal existence.

  • GET /api/session — adds _session_visible_to_active_profile(session_profile, handler) immediately after get_session on the WebUI path and after _lookup_cli_session_metadata on the CLI fallback; the active profile is resolved via TLS (_get_active_profile_name).
  • GET /api/session/export — adds an inline _profiles_match(getattr(s, "profile", None), get_active_profile_name()) guard before writing the JSON attachment.
  • Six targeted tests cover foreign-profile rejection (transcript, metadata-only, cookieless, CLI-fallback, export) and same-profile pass-through; two pre-existing tests are fixed by monkeypatching _get_active_profile_name to match the profile already in use by those sessions.

Confidence Score: 4/5

The profile guards are correctly positioned before any data is emitted, return a neutral 404, and preserve the default/legacy-root alias path; the only rough edges are minor style inconsistencies between the helper and the export inline check.

The three enforcement sites are each covered by dedicated tests, the TLS-based profile resolution is consistent with how /api/sessions works, and the early-return placement is correct. Two small style inconsistencies exist but neither produces incorrect behaviour with real-world string-or-None profile values.

api/routes.py — specifically the _session_visible_to_active_profile helper signature and the export path inline _profiles_match call

Important Files Changed

Filename Overview
api/routes.py Adds profile-scoping guard to GET /api/session and GET /api/session/export; uses a new _session_visible_to_active_profile helper for the detail paths and an inline _profiles_match check for export; profile check positioned correctly (before any data emission) in all three places.
tests/test_issue1611_session_profile_filtering.py Adds 6 new tests covering foreign-profile rejection and same-profile pass-through for both the detail endpoint (WebUI session, metadata-only, cookieless, CLI fallback) and the export endpoint; all tests properly mock the profile name, session loader, and response sinks.
tests/test_issue2762_state_sync_profile_kwarg.py Adds a monkeypatch for _get_active_profile_name to return "maiko" so the new profile gate does not block the pre-existing metadata-only load test that was already operating in that profile.
tests/test_webui_state_db_reconciliation.py Adds monkeypatch for _get_active_profile_name to keep the deferred-model-resolution test passing now that the detail endpoint enforces the profile boundary.
CHANGELOG.md Adds v0.51.443 release entry with a Security section describing the two fixed by-id endpoints.

Sequence Diagram

sequenceDiagram
    participant C as Client
    participant R as routes.py
    participant P as api/profiles.py (TLS)
    participant S as Session Store

    Note over R,P: GET /api/session?session_id=…

    C->>R: "GET /api/session?session_id=X"
    R->>S: get_session(X)
    S-->>R: "session (profile=other)"
    R->>P: _get_active_profile_name() [TLS]
    P-->>R: default
    R->>R: "_session_visible_to_active_profile(other, handler) = False"
    R-->>C: 404 Session not found

    Note over R,P: GET /api/session?session_id=… (CLI fallback)

    C->>R: "GET /api/session?session_id=Y"
    R->>S: get_session(Y) raises KeyError
    R->>R: check SESSION_INDEX_FILE
    R->>S: _lookup_cli_session_metadata(Y)
    S-->>R: "profile=other"
    R->>P: _get_active_profile_name() [TLS]
    P-->>R: default
    R->>R: "_session_visible_to_active_profile(other, handler) = False"
    R-->>C: 404 Session not found

    Note over R,P: GET /api/session/export?session_id=…

    C->>R: "GET /api/session/export?session_id=Z"
    R->>S: get_session(Z)
    S-->>R: "session (profile=other)"
    R->>P: get_active_profile_name() [TLS]
    P-->>R: default
    R->>R: "_profiles_match(other, default) = False"
    R-->>C: 404 Session not found
Loading

Reviews (1): Last reviewed commit: "Release PD: [security] scope session by-..." | Re-trigger Greptile

Comment thread api/routes.py
Comment on lines +445 to +458
def _session_visible_to_active_profile(session_profile, handler=None) -> bool:
"""Return whether a detail-load session belongs to the active profile.

Real request handlers must enforce the same profile boundary as
/api/sessions, even when the request has no hermes_profile cookie and the
process-level active profile is the default/root profile. Direct unit-callers
without a request handler keep the historical metadata-load behavior.
"""
if handler is None:
return True
active_profile = _get_active_profile_name()
if not isinstance(session_profile, str):
session_profile = None
return _profiles_match(session_profile, active_profile)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 handler parameter acts only as a sentinel

_session_visible_to_active_profile accepts handler but never reads any value from it — it only checks handler is None to decide whether to enforce the profile boundary. The actual profile is read from _get_active_profile_name() (TLS), not from the handler object. A reader unfamiliar with the codebase will naturally expect the handler to be consulted for cookie/header data, and might look for profile extraction code that isn't there.

A boolean flag (e.g. enforce: bool = False) would make the contract explicit, or the docstring could note explicitly that profile resolution is TLS-based and the handler is a presence-only sentinel.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment thread api/routes.py
Comment on lines +10224 to +10226
active_profile = get_active_profile_name()
if not _profiles_match(getattr(s, "profile", None), active_profile):
return bad(handler, "Session not found", 404)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Export uses inline check while detail paths use the helper

The two detail-path guards route through _session_visible_to_active_profile (which normalises non-string profiles to None before passing to _profiles_match), but the export guard calls _profiles_match directly with the raw getattr value. For the current codebase this makes no observable difference because profile is always a string or None. However, if a session ever carried a non-string truthy profile (e.g., an integer migration artefact), the export path would evaluate row = 42 or 'default'42, which would not match 'default', producing a spurious 404 for a same-profile session. Routing through _session_visible_to_active_profile (or at minimum mirroring the isinstance normalisation) would keep the three paths consistent.

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.

1 participant