Skip to content

fix(chat): refuse linking a file the caller does not own - #748

Merged
DEENUU1 merged 2 commits into
mainfrom
fix/706-chat-file-owner
Aug 13, 2026
Merged

DEENUU1 merged 2 commits into
mainfrom
fix/706-chat-file-owner

Conversation

@DEENUU1

@DEENUU1 DEENUU1 commented Aug 13, 2026

Copy link
Copy Markdown
Member

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 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_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

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 DEENUU1 left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread backend/app/services/conversation.py Outdated
Comment thread backend/app/repositories/chat_file.py
Comment thread backend/tests/integration/test_chat_file_ownership.py Outdated
@DEENUU1 DEENUU1 self-assigned this Aug 13, 2026
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
@DEENUU1
DEENUU1 merged commit 9028519 into main Aug 13, 2026
14 checks passed
@DEENUU1
DEENUU1 deleted the fix/706-chat-file-owner branch August 13, 2026 21:07
@DEENUU1 DEENUU1 mentioned this pull request Aug 13, 2026
DEENUU1 added a commit that referenced this pull request Aug 13, 2026
Refuse linking a file the caller does not own — #748, closes #706.

Version bumped in `backend/pyproject.toml`, `backend/uv.lock` and
`frontend/package.json`. The CHANGELOG entry is written here rather than
promoted from `[Unreleased]`, because #748 wrote none.
DEENUU1 added a commit that referenced this pull request Aug 13, 2026
#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.
DEENUU1 added a commit that referenced this pull request Aug 15, 2026
## 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
DEENUU1 added a commit that referenced this pull request Aug 15, 2026
Refuse linking a file the caller does not own — #748, closes #706.

Version bumped in `backend/pyproject.toml`, `backend/uv.lock` and
`frontend/package.json`. The CHANGELOG entry is written here rather than
promoted from `[Unreleased]`, because #748 wrote none.
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.

A chat turn can link another user's file to its own message

1 participant