Repository navigation
security(chat): get_session_owner cannot distinguish 'no owner' from 'unreadable', and callers now WRITE on that answer #14033
Description
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:
- security(chat): session ownership expires after 24h and is then claimed by whoever accesses it next #14018 — an absent Redis ownership record is resolved against this lookup. A
Nonefrom an unreadable file reads as "genuinely unowned", so the session takes the claim path. - security(chat): save_chat_by_id can create a session with no durable owner, leaving it permanently reclaimable #14020 / PR security(chat): write the durable owner when the save path creates a session (#14020) #14025 — the save path writes owner metadata based on the ownership outcome.
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:
get_session_ownerdistinguishes absent (no file, or a file with no owner →None) from unreadable (decrypt/corruption/IO → raise, or return a distinct sentinel).- Callers treat "unreadable" as unknown, not as unowned: deny or defer rather than claim, and never write an owner on that path.
_load_existing_chat_datalikewise 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
- A decrypt/IO failure is distinguishable from "no owner recorded" at the call site
- Test: an unreadable session file does not result in a claim, and does not result in an owner being written
- Test: a genuinely ownerless session still behaves as today
- A corrupted read no longer silently discards existing metadata on save
- The remaining ownership lookups share the contract (see tech-debt(security): two different validate_session_ownership functions — same name, different signature and semantics #14016, security(chat): shared-link endpoints check ownership against the expiring Redis key only — the real owner is locked out after 24h #14021)
Related
- security(chat): session ownership expires after 24h and is then claimed by whoever accesses it next #14018 — made the file record authoritative, which is what turned this into an authorization input
- security(chat): save_chat_by_id can create a session with no durable owner, leaving it permanently reclaimable #14020 / PR security(chat): write the durable owner when the save path creates a session (#14020) #14025 — the write path; narrowed but did not remove the exposure
- tech-debt(security): two different validate_session_ownership functions — same name, different signature and semantics #14016 — two functions named
validate_session_ownership - security(chat): shared-link endpoints check ownership against the expiring Redis key only — the real owner is locked out after 24h #14021 — shared-link endpoints use a third, raw lookup
- security(chat): ownership enforcement fails OPEN — an unavailable feature-flag service disables every check platform-wide #14010 — enforcement fails open
Activity
fix(security): use a bare 403 to match the module's convention (#14033)
fix(security): keep every touched file within its recorded size ceili…
fix(security): own imported sessions and stop unreadable ownership re…
mrveiss commentedon Sep 1, 2026
Closing — delivered by #15433, merged as
a41a0e4e82. Verified against merged code onDev_new_gui.get_session_ownerreturnedNonefor four distinct states: the file does not exist, it exists with no owner recorded, anOSErrorreading 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
Nonethree different ways:Nonechat_shared_links.py:121live_events.py:109conversation_files.py:209return True— "allow access (legacy session)"websockets.py:559allowed = owner is None or …So a transient read error — permissions, corruption, a full disk — handed another user's conversation files over.
Fixed.
Nonenow means one thing:Three of the four callers already wrap the call in
except Exceptionand fail closed, so they became correct without changing —websockets.pyalready logged "withholding" on exception, which is now the path an unreadable file actually takes.conversation_files.pyhad notry, 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, becausesecurity/session_ownership.py,chat_history/session.pyandapi/conversation_files.pyall sit at recorded size ceilings that equal their base size — a grandfathered file may not grow at all. My first attempt put it insession_ownership.pyand pushed a third file over its ceiling.A real bug was caught by that work:
session.pyraisedSessionOwnerUnreadablewithout importing it — aNameErrorat runtime — because a re-export edit silently no-opped and a guard clause then saw the name in theraisestatement 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).