Skip to content

security(chat): get_session_owner cannot distinguish 'no owner' from 'unreadable', and callers now WRITE on that answer #14033

Closed

Description

@mrveiss

Found while reviewing PR #14025 (#14020). Pre-existing error contract, newly load-bearing.

Problem

ChatHistoryManager.get_session_owner (autobot-backend/chat_history/session.py) collapses three different situations into a single None:

  • the session has no owner recorded (a genuine legacy session)
  • the session file does not exist
  • the file exists but is unreadable — decrypt failure, corruption, disk error

It swallows both OSError and Exception internally and returns None for all of them. _load_existing_chat_data does the same, returning {} on a decrypt failure.

Why it matters more now

Until recently that answer only drove reads. Two recent changes made it drive writes and authorization:

So a transient decrypt failure or disk hiccup on a session that does have an owner can be read as unowned, and the resulting claim can then be written to the file, permanently reassigning ownership on a momentary glitch. _build_session_chat_data merges with existing_metadata.update(metadata), so the owner/username keys are overwritten rather than preserved.

The same corrupted-read event also silently drops the rest of the metadata (org_id, team_id, thinking_preferences, title), because _load_existing_chat_data returns {}.

PR #14025 narrows the exposure — it now writes only when the validator reports legacy_migration, and never on the unchecked fast paths — but it does not remove it: the validator itself reaches legacy_migration via the same collapsed None.

Suggested approach

Give the lookup an honest error contract:

  1. get_session_owner distinguishes absent (no file, or a file with no owner → None) from unreadable (decrypt/corruption/IO → raise, or return a distinct sentinel).
  2. Callers treat "unreadable" as unknown, not as unowned: deny or defer rather than claim, and never write an owner on that path.
  3. _load_existing_chat_data likewise should not report a corrupted file as an empty dict, since that silently discards metadata on save.

This is the natural companion to #14016 and #14021 — consolidating the ownership lookups is the moment to give them one error contract as well as one implementation.

Acceptance criteria

Related

Activity

  1. mrveiss commented on Sep 1, 2026

    @mrveiss
    OwnerAuthor

    Closing — delivered by #15433, merged as a41a0e4e82. Verified against merged code on Dev_new_gui.

    get_session_owner returned None for four distinct states: the file does not exist, it exists with no owner recorded, an OSError reading it, and a decrypt failure. A caller could not tell "nobody owns this" from "I could not find out".

    That mattered because its four callers read the single None three different ways:

    caller on None
    chat_shared_links.py:121 denies fails closed
    live_events.py:109 denies, citing #14819 fails closed
    conversation_files.py:209 return True — "allow access (legacy session)" failed open
    websockets.py:559 allowed = owner is None or … failed open

    So a transient read error — permissions, corruption, a full disk — handed another user's conversation files over.

    Fixed. None now means one thing:

    security/session_owner_errors.py   class SessionOwnerUnreadable
    chat_history/session.py            raise SessionOwnerUnreadable(...)
    api/conversation_files.py          except SessionOwnerUnreadable -> 403
    

    Three of the four callers already wrap the call in except Exception and fail closed, so they became correct without changing — websockets.py already logged "withholding" on exception, which is now the path an unreadable file actually takes. conversation_files.py had no try, so it gained one.

    A session that genuinely records no owner still reads, so legacy sessions are unaffected. Whether that policy should change is the separate question on #14010 and was deliberately left alone.

    One structural note. The exception lives in its own module rather than beside build_owner_metadata, because security/session_ownership.py, chat_history/session.py and api/conversation_files.py all sit at recorded size ceilings that equal their base size — a grandfathered file may not grow at all. My first attempt put it in session_ownership.py and pushed a third file over its ceiling.

    A real bug was caught by that work: session.py raised SessionOwnerUnreadable without importing it — a NameError at runtime — because a re-export edit silently no-opped and a guard clause then saw the name in the raise statement and skipped adding the import. Surfaced only when test collection broke.

    Related: #14026 (closed alongside — the import path that created the ownerless sessions those callers trusted), #14010 (the unowned-means-public policy, needs-decision).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions