You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Multiple _attachment@1 records per fileId is intentional and stays: different uploaders remember the same file under different names, and the metadata record is each uploader's perspective (the spec already leans into this — the download path serves the filename from the requester's own metadata record, spec §Attachments). This issue does not change that.
The defect is that one field in that record is not a perspective. filename is a memory aid for a human; mimeType is an instruction to the client about how to execute the bytes — text/html parses and runs a document on the serving origin, image/png renders pixels, application/octet-stream downloads inert. Two records disagreeing about filename is healthy pluralism; two records disagreeing about mimeType means someone is wrong, and today the server has no rule for who.
Mechanics: every putAttachment() creates a fresh metadata record with the uploader's declared type (packages/core/src/stack.ts:604–613), and content-addressing maps identical bytes to the same fileId (spec: "uploading identical bytes twice returns the same fileId") — so a second uploader doesn't create a second file, they attach a second, possibly contradictory, execution claim to the first uploader's bytes. The no-param download path then reads "the stored mimeType" (spec §Attachments) — which record is unspecified, so it varies with sort order and, since metadata records carry their own permissions, with who's asking.
Failure modes:
Nondeterminism among honest uploaders. The same file uploaded as text/markdown by one person and text/plain by another → which one a browser receives depends on record ordering; any HTTP cache in between pins one answer and serves it to everyone.
Escalation with one dishonest uploader. A requester with a create grant on _attachment@1 takes bytes someone else already stored (an image referenced from a shared record), uploads the identical bytes, declares text/html. No write access to any referencing record was needed — dedup attached the claim for them. Polyglot files (simultaneously valid PNG and valid HTML — a standard construction) make the payload real: if the server's arbitrary pick lands on the attacker's record, a stored "image" is served as an executable document on the stack's origin, scripts and all. Composes with the header-forcing gaps (companion issue, filed next).
Decided direction
Split the record's fields by what kind of fact they are:
mimeType is a property of the fileId: first-recorded wins for serving. The first _attachment@1 record created for a fileId fixes the served Content-Type. Later same-bytes uploads still create their own metadata records (filename, uploader attribution, size) — but a conflicting mimeType is rejected with StackValidationError (422) rather than stored-and-ignored: a contradictory execution claim shouldn't survive in the data to confuse the next reader. A matching type is fine.
Enforcement lives in record validation, not just the convenience method. The spec's upload flow creates metadata via plain POST /records (§Attachments, "Upload"), so the check must run wherever an _attachment@1 record is created or updated: look up existing metadata for the same content.fileId (exhaustive per Core code treats one query() page as the complete result set (grants, attachment metadata, uploader checks) #50) and compare mimeType. putAttachment() gets it via the same path.
filename stays per-perspective — the spec's requester's-own-record rule is affirmed as-is, falling back to the first record's filename.
Legitimate type choice survives via the existing explicit ?contentType parameter (subject to dangerous-type forcing) — determinism removes the arbitrary pick, not the requester's ability to ask.
First-recorded can still be a lie — deliberately out of scope here; the companion header-forcing issue is the backstop that makes a lie inert.
Problem
Multiple
_attachment@1records per fileId is intentional and stays: different uploaders remember the same file under different names, and the metadata record is each uploader's perspective (the spec already leans into this — the download path serves the filename from the requester's own metadata record, spec §Attachments). This issue does not change that.The defect is that one field in that record is not a perspective.
filenameis a memory aid for a human;mimeTypeis an instruction to the client about how to execute the bytes —text/htmlparses and runs a document on the serving origin,image/pngrenders pixels,application/octet-streamdownloads inert. Two records disagreeing aboutfilenameis healthy pluralism; two records disagreeing aboutmimeTypemeans someone is wrong, and today the server has no rule for who.Mechanics: every
putAttachment()creates a fresh metadata record with the uploader's declared type (packages/core/src/stack.ts:604–613), and content-addressing maps identical bytes to the same fileId (spec: "uploading identical bytes twice returns the same fileId") — so a second uploader doesn't create a second file, they attach a second, possibly contradictory, execution claim to the first uploader's bytes. The no-param download path then reads "the storedmimeType" (spec §Attachments) — which record is unspecified, so it varies with sort order and, since metadata records carry their own permissions, with who's asking.Failure modes:
text/markdownby one person andtext/plainby another → which one a browser receives depends on record ordering; any HTTP cache in between pins one answer and serves it to everyone._attachment@1takes bytes someone else already stored (an image referenced from a shared record), uploads the identical bytes, declarestext/html. No write access to any referencing record was needed — dedup attached the claim for them. Polyglot files (simultaneously valid PNG and valid HTML — a standard construction) make the payload real: if the server's arbitrary pick lands on the attacker's record, a stored "image" is served as an executable document on the stack's origin, scripts and all. Composes with the header-forcing gaps (companion issue, filed next).Decided direction
Split the record's fields by what kind of fact they are:
mimeTypeis a property of the fileId: first-recorded wins for serving. The first_attachment@1record created for a fileId fixes the servedContent-Type. Later same-bytes uploads still create their own metadata records (filename, uploader attribution, size) — but a conflictingmimeTypeis rejected withStackValidationError(422) rather than stored-and-ignored: a contradictory execution claim shouldn't survive in the data to confuse the next reader. A matching type is fine.POST /records(§Attachments, "Upload"), so the check must run wherever an_attachment@1record is created or updated: look up existing metadata for the samecontent.fileId(exhaustive per Core code treats one query() page as the complete result set (grants, attachment metadata, uploader checks) #50) and comparemimeType.putAttachment()gets it via the same path.filenamestays per-perspective — the spec's requester's-own-record rule is affirmed as-is, falling back to the first record's filename.?contentTypeparameter (subject to dangerous-type forcing) — determinism removes the arbitrary pick, not the requester's ability to ask.Work items
_attachment@1create/update rejects conflictingmimeTypefor an existing fileId (core + reaches the server via ordinary record validation; 422 per APIAdapter collapses the error taxonomy: Stack* errors never survive the wire round trip #53)mimeTypeis served"; conflict-rejection rule; filename perspective rule affirmedRefs #50 (exhaustive metadata lookup), #52 (fixtures), #53 (error round-trip), #51 (the create-grant threat actor this defends against), companion header-forcing issue (backstop).