fix(service): enforce attachment ownership atomically in the link UPDATE (W1-3)

The per-attachment GetAttachmentByID pre-check loop was a check-then-link
TOCTOU (the same race pattern this branch fixes elsewhere), an N+1 on the
hot send path, and a hard ErrForbidden for legit retries naming an
already-linked attachment. Ownership now lives in the one UPDATE that
links: `AND message_id IS NULL AND (uploader_id = ? OR uploader_id IS
NULL)` — a foreign attachment can never be claimed, legacy NULL-uploader
rows stay claimable, and skipped rows (foreign/linked/missing) are logged
but never fail the send, so retries can't hard-fail. Subsumes W2-4; the
MemStore (nil,nil) GetAttachmentByID contortion is replaced by a real
map-backed attachment store so the guard is testable (W3-5).

Companion commit updates test callsites and adds ownership coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
J3vb
2026-07-19 08:29:26 +02:00
co-authored by Claude Fable 5
parent a3f7d63f7d
commit 4b37c8024e
5 changed files with 97 additions and 61 deletions
+14 -5
View File
@@ -91,23 +91,32 @@ func (d *DB) GetAttachmentWithChannel(id string) (*AttachmentAccess, error) {
}
// LinkAttachmentsToMessage sets message_id on attachments that are currently
// unlinked (message_id IS NULL). Returns the number of rows updated.
// Uses WHERE message_id IS NULL to prevent double-linking in a race.
func (d *DB) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) {
// unlinked (message_id IS NULL) and owned by uploaderID. Legacy rows with
// uploader_id IS NULL are treated as unowned and may be claimed by any
// sender. Rows that are already linked, owned by another user, or
// nonexistent are skipped rather than errors, so a client retry of a
// partially-completed send cannot fail the whole message. This single UPDATE
// is the atomic attachment-IDOR guard for message sends: ownership is
// enforced in the same statement that links, so there is no check-then-link
// race. Returns the number of rows updated.
func (d *DB) LinkAttachmentsToMessage(messageID, uploaderID int64, attachmentIDs []string) (int64, error) {
if len(attachmentIDs) == 0 {
return 0, nil
}
placeholders := make([]string, len(attachmentIDs))
args := make([]any, 0, len(attachmentIDs)+1)
args := make([]any, 0, len(attachmentIDs)+2)
args = append(args, messageID)
for i, id := range attachmentIDs {
placeholders[i] = "?"
args = append(args, id)
}
args = append(args, uploaderID)
query := fmt.Sprintf( //nolint:gosec // G201: placeholder interpolation, not user input
`UPDATE attachments SET message_id = ? WHERE id IN (%s) AND message_id IS NULL`,
`UPDATE attachments SET message_id = ?
WHERE id IN (%s) AND message_id IS NULL
AND (uploader_id = ? OR uploader_id IS NULL)`,
strings.Join(placeholders, ","),
)
res, err := d.sqlDB.Exec(query, args...)