Skip to content

SECURITY: fix cross-user conversation-history leak in Python get_conversation_messages - #304

Merged
brentrager merged 1 commit into
mainfrom
th-1b7ed0-py-chokepoint
Jul 20, 2026
Merged

SECURITY: fix cross-user conversation-history leak in Python get_conversation_messages#304
brentrager merged 1 commit into
mainfrom
th-1b7ed0-py-chokepoint

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

P0 SECURITY. The per-user conversation scoping fix (th-8fe998, #295) added the _visible_session ownership chokepoint to the Python dispatcher and routed get_session, send_message and verify_otp through it — but _handle_get_conversation_messages still called self._store.get_session directly:

session = await self._store.get_session(session_id)

No owner check. Any authenticated user could read any other user's full conversation history by sessionId — the exact endpoint the security epic was reported about. The Go, TypeScript, Rust and C# lanes were clean; Python had the helper but one handler skipped it.

Solution

get_conversation_messages now routes through _visible_session, and reports a session it does not own with the byte-identical SESSION_NOT_FOUND payload already emitted for an id that never existed — no new error code, no reworded message, so the endpoint is not an existence oracle for enumerating other users' session ids.

Handler audit

Every sessionId-taking handler in the dispatch chain:

Handler Turns sessionId into a session? Before After
get_conversation_messages yes — direct _store.get_session UNPROTECTED _visible_session
get_session yes _visible_session unchanged
send_message yes _visible_session unchanged
verify_otp yes _visible_session unchanged
confirm_tool_action no store lookup — resolves against a per-connection ConfirmationRegistry (server.py:131, one per WebSocket connection), so a foreign sessionId is a NO_PENDING_CONFIRMATION no-op and cannot reach another principal's session n/a unchanged
create_conversation_session, list_conversations, cancel, ping take no sessionId (cancel is connection-local; the other two were scoped by #295) n/a unchanged

self._store.get_session now has exactly one call site: inside _visible_session itself.

Making the bypass structurally hard to repeat

  • _visible_session's doc comment now states it is the ONLY permitted way to turn a client-supplied sessionId into a session (mirroring Go's scopedSession), and names this bug as what happens otherwise.
  • test_visible_session_is_the_only_lookup asserts the dispatcher source contains exactly one direct self._store.get_session( call — a future handler that skips the chokepoint fails a test.

Verification

Added to the existing security test file from #295:

  • test_get_conversation_messages_leaks_no_history_across_users — the headline case: user A reads user B's sessionId ⇒ SESSION_NOT_FOUND, and the assertion is on the payload (no bob secret, no messages key), not just the error code.
  • get_conversation_messages added to both existing parametrized suites, including test_not_yours_is_byte_identical_to_never_existed (the oracle test).
  • test_owner_can_read_their_own_conversation_messages — the legitimate read still works.
  • Auth-disabled behavior preserved: the existing test_get_conversation_messages.py suite (unscoped, single-tenant) passes unchanged.

All four new/extended cases were confirmed to FAIL against the pre-fix code (fix temporarily reverted, 4 failed / 19 passed) and pass with it.

Full Python gates green:

uv run pytest        → 203 passed
uv run ruff format --check .  → 39 files already formatted
uv run ruff check .  → All checks passed!

No PyPI publish in this PR.

Pearl: th-1b7ed0. Follow-up to SECURITY epic th-8fe998 (#295).

🤖 Generated with Claude Code

https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F

…ership chokepoint

The th-8fe998 scoping fix added `_visible_session` and routed get_session,
send_message and verify_otp through it, but _handle_get_conversation_messages
still called `self._store.get_session` directly. Any authenticated user could
therefore read any other user's full conversation history by sessionId — the
exact endpoint the security epic was reported about.

It now goes through the chokepoint and reports a session it does not own with
the byte-identical SESSION_NOT_FOUND payload used for an id that never existed,
so the endpoint is not an existence oracle.

Audited every other sessionId-taking handler: get_session, send_message and
verify_otp were already routed; confirm_tool_action takes no store lookup and
resolves against a per-connection ConfirmationRegistry, so it cannot reach
another principal's session. This was the only bypass.

Tests: the headline cross-user read (asserted on the payload, not just the
error code), the not-yours-vs-never-existed byte-identical case, the owner's
own read still working, and a structural test asserting `_visible_session` is
the ONLY `self._store.get_session` call site — so a future handler that skips
the chokepoint fails a test. All four fail without the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01331RwggrhiP9UJVao9dr1F
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: a9605ca

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@smooai/smooth-operator Patch
@smooai/smooth-operator-web-chat-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@brentrager
brentrager merged commit 770edec into main Jul 20, 2026
2 checks passed
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