Skip to content

Served Content-Type is nondeterministic when multiple _attachment records share a fileId — make mimeType a property of the bytes #65

Description

@cuibonobo

Problem

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 bytestext/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:

  1. 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.
  2. 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:

  1. 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.
  2. 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.
  3. filename stays per-perspective — the spec's requester's-own-record rule is affirmed as-is, falling back to the first record's filename.
  4. 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.
  5. 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.

Work items

Refs #50 (exhaustive metadata lookup), #52 (fixtures), #53 (error round-trip), #51 (the create-grant threat actor this defends against), companion header-forcing issue (backstop).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions