Root cause
Authentication & access control
fix: prevent state.db messages being silently dropped during sidecar merge
How AI contributed: New attack surfacePublished Jun 17, 2026 in
nesquena/hermes-webuiAll 6 findings in this repository
Hermes WebUI before 0.51.443 contains a broken access control vulnerability in the /api/session endpoint that allows authenticated users to disclose cross-profile session transcripts. Attackers can bypass profile boundary checks by directly querying session IDs belonging to other profiles via GET /api/session?session_id=<foreign_id>&messages=1 to retrieve unauthorized conversation transcripts and metadata.
AI-written code failed to enforce profile ownership checks on the session API, letting authenticated users read other users’ private transcripts and metadata.
Root cause
fix: prevent state.db messages being silently dropped during sidecar merge
Fix
Release PD (v0.51.443): [security] scope session by-id reads + exports to active profile (#3982, #3991) (#4269)
Case facts
--- a/api/routes.py+++ b/api/routes.py@@ -3752,17 +3752,18 @@ def handle_get(handler, parsed) -> bool: cli_messages = [] state_db_messages = [] sidecar_metadata_messages = None+ _session_profile = getattr(s, 'profile', None) or None if is_messaging_session: cli_messages = get_cli_session_messages(sid) elif load_messages:- state_db_messages = get_state_db_session_messages(sid)+ state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) elif not is_messaging_session: # Metadata-only callers still need the same append-only # reconciliation contract as full loads. A raw state.db summary # can count stale rows that the merge intentionally filters out, # which makes sidebar polling think the transcript is always # newer than the loaded conversation.- state_db_messages = get_state_db_session_messages(sid)+ state_db_messages = get_state_db_session_messages(sid, profile=_session_profile) sidecar_metadata_session = Session.load(sid) sidecar_metadata_messages = ( getattr(sidecar_metadata_session, "messages", []) or []--- a/api/routes.py+++ b/api/routes.py@@ -424,7 +424,11 @@ def _visible_pinned_lineage_ids(session_rows) -> set[str]: # (mcp_server.py) can import it without duplicating the visibility model. # Re-exported here so existing `_profiles_match(...)` call sites in this # module keep resolving without per-call-site refactors.-from api.profiles import _profiles_match # noqa: F401, E402 (re-export)+from api.profiles import ( # noqa: F401, E402 (re-export)+ _profiles_match,+ get_active_profile_name,+ get_active_profile_name as _get_active_profile_name,+) def _all_profiles_query_flag(parsed_url) -> bool:@@ -438,6 +442,22 @@ def _all_profiles_query_flag(parsed_url) -> bool: return raw in ('1', 'true', 'yes', 'on') +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)++ def _active_skills_dir() -> Path: """Return the skills directory for the request's active Hermes profile. @@ -6829,6 +6849,9 @@ def handle_get(handler, parsed) -> bool: try: _t1 = _time.monotonic() s = get_session(sid, metadata_only=(not load_messages))+ _session_profile = getattr(s, 'profile', None) or None+ if not _session_visible_to_active_profile(_session_profile, handler):+ return bad(handler, "Session not found", 404) original_stream_id = getattr(s, "active_stream_id", None) _clear_stale_stream_state(s) cli_meta = _lookup_cli_session_metadata(sid) if _session_requires_cli_metadata_lookup(s) else {}@@ -6836,7 +6859,6 @@ def handle_get(handler, parsed) -> bool: cli_messages = [] state_db_messages = [] metadata_summary = None- _session_profile = getattr(s, 'profile', None) or None if is_messaging_session: cli_messages = get_cli_session_messages(sid) elif load_messages:@@ -7124,6 +7146,9 @@ def handle_get(handler, parsed) -> bool: if _was_webui_session: return bad(handler, "Session not found", 404) cli_meta = _lookup_cli_session_metadata(sid)+ _session_profile = (cli_meta or {}).get("profile") or None+ if not _session_visible_to_active_profile(_session_profile, handler):+ return bad(handler, "Session not found", 404) msgs = get_cli_session_messages(sid) if msgs: sess = {@@ -10196,6 +10221,9 @@ def _handle_session_export(handler, parsed): s = get_session(sid) except KeyError: return bad(handler, "Session not found", 404)+ active_profile = get_active_profile_name()+ if not _profiles_match(getattr(s, "profile", None), active_profile):+ return bad(handler, "Session not found", 404) safe = redact_session_data(s.__dict__) payload = json.dumps(safe, ensure_ascii=False, indent=2) handler.send_response(200)AI-assisted change cc679669a8d36206a09872e52a89b754576c1e628198130f96ea8c189fdbdc16 · Fix 72a648cb28e02e67bfa1eacc260e0966968637b655d04e13dea0539d6fcf97e4