Repository navigation
fix(chat): refuse linking a file the caller does not own - #748
Conversation
A web-chat turn could attach another user's file to its own message by sending that file's id: `chat_file_repo.link_to_message` was a blind bulk UPDATE with no owner predicate and no unlinked check, and `get_many` filtered on id alone. The victim's filename, MIME type and size rendered in the attacker's conversation, and a non-NULL `message_id` was overwritten, so the file silently moved off the victim's own message. `chat_files` carries no organization, so `user_id` is the only scope a row has. - `get_many` and `link_to_message` carry the owner in their WHERE, and the UPDATE also requires `message_id IS NULL`. - `ConversationService.link_files_to_message` reads the rows first and refuses: a foreign or unknown id is NotFoundError (indistinguishable on purpose, so ids stay unprobeable), an already-linked one BadRequestError. A file on a message never moves - no caller legitimately re-links: web chat links fresh uploads once, channel rows are created unlinked in the same turn, the embed already drops a spent id. - The refusal escapes `persist_user_turn`'s swallow, which now covers only infrastructure failures, so the socket answers with an error frame instead of a turn that quietly dropped the attachment. - The channel transcript links rows as their own uploader; the embed's owner check moved into the query, and a page whose publisher's account is gone (owner SET NULL) reads no rows at all. Verified: tests/integration/test_chat_file_ownership.py runs the cross-user attempt against a real database and reads both rows back; unit tests cover the refusals and the owner riding through every caller; the pre-existing channel-turn integration test proves server-side rows still link through the new predicates. make lint and make test (100% gate) pass. Closes #706
DEENUU1
left a comment
There was a problem hiding this comment.
Self-review before merge, since the automated reviewer is still off (#311).
The shape is right: the owner predicate lives in the UPDATE itself, so the one caller that skips the service pre-read (the channel transcript) is still safe, and the integration tests read the rows back instead of trusting mocks. I traced every caller of get_many / link_to_message / list_attached_files / load_attached_files — all of them are in this diff, and link_to_message is the only writer of ChatFile.message_id anywhere, so the sibling audit is clean. The rollback path also holds: a refusal escapes persist_user_turn, the session context manager rolls the already-written user message back, and the socket handler turns it into an error frame.
Two non-blocking suggestions inline: a malformed (non-UUID) file id still falls on the wrong side of the new refusal/infrastructure line, and the UPDATE's rowcount is worth checking so a cross-session race can't silently narrow.
Two gaps the review of this branch found in the new refusal, both in `link_files_to_message`: - An id that is not a UUID at all raised `ValueError`, which `persist_user_turn`'s infrastructure net swallowed as a transient persistence failure - the turn went ahead and then died a step later in `list_attached_files` as a generic failed turn, after the message had been persisted and the `user_prompt` frame sent. It is now the same loud `BadRequestError` a malformed conversation id gets, naming only the malformed values. Parsed through `str()` first, because the socket payload is untyped JSON and a number or null in the list must land in the refusal, not raise `TypeError` past it. - The service's pre-read and the repository's UPDATE are two statements, so two concurrent turns naming the same fresh upload both passed the read and the loser's UPDATE silently matched zero rows - the silent narrowing this branch exists to refuse, relocated into the race window. `link_to_message` now reports its rowcount and the service refuses when it comes up short. Under READ COMMITTED the losing UPDATE blocks on the winner's row lock and re-evaluates `message_id IS NULL`, so the count is the truth by the time it is read. Verified with unit tests for both refusals and the integration suite against a real database, which now also asserts the count both ways (1 for the owner's link, 0 for a foreign row). The race itself is not reproduced in a test - simulating two interleaved sessions is not worth the machinery - so the short-count path is covered at the service boundary with a mocked repository. Part-of #706
#748 gave list_attached_files an owner predicate, so the naming path added here hands it the same user the linking already names - a blank turn must not name a file its sender does not own any more than the model may read one.
## What A web-chat turn could attach **another user's file** to its own message by sending that file's id. `chat_file_repo.link_to_message` was a blind bulk UPDATE — no owner predicate, no unlinked check — and `get_many` filtered on id alone, with the ids arriving straight off the socket payload. Two consequences from the one missing predicate: the victim's filename/MIME/size rendered in the attacker's conversation, and a non-NULL `message_id` was overwritten, so the file silently moved off the victim's own message. `chat_files` carries no `organization_id`, so `user_id` is the only scope a row has — and now both the read and the update carry it: - `chat_file_repo.get_many` and `link_to_message` take the owner in their `WHERE`; the UPDATE also requires `message_id IS NULL`. - `ConversationService.link_files_to_message` reads the rows first and **refuses** rather than silently narrowing: a foreign or unknown id raises `NotFoundError` (deliberately indistinguishable, so an id cannot be probed for existence), an already-linked one raises `BadRequestError`. Both name only `file_ids` the caller sent — nothing of the victim's. - The refusal escapes `persist_user_turn`'s swallow (which now covers only infrastructure failures), so the socket answers with an error frame instead of a turn that quietly dropped the attachment. - The channel transcript (`TranscriptService._attach`, the second caller added by #703/#690) links rows as their own uploader; the embed's owner check moved from Python into the query, and a page whose publisher's account is gone (`owner_user_id` is `SET NULL`) reads no rows at all instead of passing `None` into the predicate. ## The re-link decision **A file already on a message never moves, not even for its owner.** No caller legitimately re-links: web chat links fresh uploads once per send (no retry resends `file_ids`), channel rows are created server-side unlinked in the same turn, and the embed already documented and dropped a spent id. The silent move only ever rewrote history, so it is refused outright (`BadRequestError`) rather than allowed for the owner. ## How verified - `backend/tests/integration/test_chat_file_ownership.py` — the issue's sequence against a real database, both rows read back: the attacker gets a refusal, the victim's message keeps its file; the unlinked-file disclosure case; the repo-level UPDATE skipping a foreign row even without the service's pre-read; the owner's own upload still linking; the re-link refusal leaving the row where it was; the scoped read returning nothing for a foreign id. - Unit tests for the service refusals, the owner riding through `persist_user_turn`, `load_attached_files`, the embed session and the channel transcript, and the ownerless-embed drop. - The pre-existing channel-turn integration test (`test_transcript_savepoint.py`) proves server-side rows still link through the new predicates. - `make lint` and `make test` (100% platform gate) pass; `docs/architecture.md` and `docs/file-processing.md` updated in the same change. Closes #706
What
A web-chat turn could attach another user's file to its own message by sending that file's id.
chat_file_repo.link_to_messagewas a blind bulk UPDATE — no owner predicate, no unlinked check — andget_manyfiltered on id alone, with the ids arriving straight off the socket payload. Two consequences from the one missing predicate: the victim's filename/MIME/size rendered in the attacker's conversation, and a non-NULLmessage_idwas overwritten, so the file silently moved off the victim's own message.chat_filescarries noorganization_id, souser_idis the only scope a row has — and now both the read and the update carry it:chat_file_repo.get_manyandlink_to_messagetake the owner in theirWHERE; the UPDATE also requiresmessage_id IS NULL.ConversationService.link_files_to_messagereads the rows first and refuses rather than silently narrowing: a foreign or unknown id raisesNotFoundError(deliberately indistinguishable, so an id cannot be probed for existence), an already-linked one raisesBadRequestError. Both name onlyfile_idsthe caller sent — nothing of the victim's.persist_user_turn's swallow (which now covers only infrastructure failures), so the socket answers with an error frame instead of a turn that quietly dropped the attachment.TranscriptService._attach, the second caller added by Link a channel turn's files to the message it wrote #703/A successful channel turn never links its ChatFile rows to a message #690) links rows as their own uploader; the embed's owner check moved from Python into the query, and a page whose publisher's account is gone (owner_user_idisSET NULL) reads no rows at all instead of passingNoneinto the predicate.The re-link decision
A file already on a message never moves, not even for its owner. No caller legitimately re-links: web chat links fresh uploads once per send (no retry resends
file_ids), channel rows are created server-side unlinked in the same turn, and the embed already documented and dropped a spent id. The silent move only ever rewrote history, so it is refused outright (BadRequestError) rather than allowed for the owner.How verified
backend/tests/integration/test_chat_file_ownership.py— the issue's sequence against a real database, both rows read back: the attacker gets a refusal, the victim's message keeps its file; the unlinked-file disclosure case; the repo-level UPDATE skipping a foreign row even without the service's pre-read; the owner's own upload still linking; the re-link refusal leaving the row where it was; the scoped read returning nothing for a foreign id.persist_user_turn,load_attached_files, the embed session and the channel transcript, and the ownerless-embed drop.test_transcript_savepoint.py) proves server-side rows still link through the new predicates.make lintandmake test(100% platform gate) pass;docs/architecture.mdanddocs/file-processing.mdupdated in the same change.Closes #706