Skip to content

Close the _attachment@1 hash-oracle: make putAttachment the only non-owner path to a metadata record #106

Description

@cuibonobo

Problem

Creating an _attachment@1 metadata record is an access-conveying act that isn't gated the way every other reference-creating path is (#51). canAccessFile() (packages/core/src/stack.ts:1829) grants a non-owner file access if they "uploaded it themselves" — operationalized as holding an _attachment@1 record with their entityId and that content.fileId. But ScopedStack.create() applies no special check for _attachment@1, and its fileId is a plain string field (deliberately not file-ref, so metadata records don't self-count as GC references — stack.ts:1571), so requireFileRefAccess() never fires on it either.

Exploit (two links):

  1. A grantee with a create grant on _attachment@1 — i.e. every uploader, by design — creates a metadata record whose content.fileId is an arbitrary guessed SHA-256.
  2. That record now satisfies the uploader clause of canAccessFile(), so getAttachment(guessedFileId) returns the bytes.

Because storage is content-addressed, the fileId is the hash of the plaintext — so anyone holding a candidate file already knows its fileId. Guessing the hash of suspected known content both confirms the stack holds those exact bytes and exfiltrates them — the precise confirmation-oracle #51's anti-oracle language was written to prevent. checkAttachmentMimeTypeOnCreate (stack.ts:1153) compounds it: even when the grab is blocked, its 422 message embeds the established mimeType for the guessed fileId (stack.ts:1176-1179), leaking existence + metadata.

This only matters behind a server (asEntity()/ScopedStack) — plain Stack is unscoped and full-trust — but the enforcement must live in ScopedStack, not in server code, so a server that merely delegates can't get it wrong.

Full writeup: docs/design-assessment-2026-07.md §A1 (PR #105).

The structural insight the fix rests on

There are exactly two ways an _attachment@1 record can be created:

  • putAttachment(data, mimeType, filename) — the caller passes bytes; the fileId is computed from them (putAttachmentBytes → SHA-256), never accepted as input. You cannot name a file you don't hold, because you don't name it at all — the hash does. Possession is proven by construction.
  • create('_attachment@1', { fileId, … }) — the caller passes a fileId string. This is the only path where the fileId is caller-controlled, and it is the entire hole.

So putAttachment is already the safe, possession-proving primitive. The fix is to make it the only non-owner path to a metadata record, and close the fileId-supplied path.

Decided direction

Core / ScopedStack (the guarantee). ScopedStack.create() refuses _attachment@1 (matched by baseId, like the existing _group special-case) for any non-owner requester — throws StackPermissionError. The owner and unscoped Stack are unchanged (full trust). Concretely, alongside the _group handling already in create():

const isOwner = requester === this.stack.ownerEntityId;
if (!isOwner && baseIdOf(typeId) === SYSTEM_TYPES.ATTACHMENT) {
  // Metadata records are access-conveying; the fileId must be derived from
  // uploaded bytes, never caller-supplied. Non-owners go through putAttachment().
  throw new StackPermissionError();
}

With this, the uploader clause of canAccessFile() is only ever satisfiable for files the requester actually uploaded (the only remaining non-owner path to a metadata record is putAttachment, which derives the fileId from supplied bytes). The exploit's precondition — a metadata record for a guessed hash — becomes unrepresentable. A server that naively routes generic POST /records for all types to scopedStack.create() gets the 403 for free, because it delegated.

Wire (to match). POST /attachments stops being bytes-only: it carries mimeType (Content-Type) and filename (Content-Disposition) — both already sent by APIAdapter.uploadBinary today and currently ignored — and the server implements it as scopedStack.putAttachment(data, mime, filename), creating bytes + metadata as one operation. Generic POST /records for _attachment@1 from a non-owner returns 403 via ScopedStack. This is the deliberate trade of the "records endpoints stay JSON-only / attachments are bytes-only" purity for a safe, atomic upload — accepted (see discussion on PR #105). Landing a wire change now is cheap per the standing "no install base beyond the owner → change contracts in place" policy (#57).

Residual decisions (both resolved here; flag if you disagree)

  1. Re-adding metadata for a file you can already see, without re-uploading bytes. A non-owner who can legitimately read a record referencing F should be able to add their own _attachment@1 (e.g. a second filename) without re-sending bytes. Resolution: allow non-owner create('_attachment@1', { fileId: F }) iff canAccessFile(F) passes via a readable referencing record only — never via the uploader clause (that would reintroduce the circularity the fix closes). Safe because it conveys no access they didn't already have. Implement as a narrow carve-out in the create() guard above.
  2. putAttachmentBytes() for non-owners becomes a dead end (bytes-only, and the follow-up create is now refused). This is already effectively true today — a bytes-only upload leaves canAccessFile false, so the file can't be associated either. Resolution: no behavior change; document that non-owners use the combined putAttachment(). Keep putAttachmentBytes for owner/server-internal use.

Anti-oracle cleanup (independent — land immediately, don't wait on the above)

Make checkAttachmentMimeTypeOnCreate's conflict rejection generic (drop the established-mimeType from the message) so it stops confirming existence/leaking metadata for a guessed fileId. The anti-oracle rule applies throughout: missing and inaccessible must produce an identical StackPermissionError with no distinguishing detail.

Adjacent findings folded in (same PR, cheaply)

Collapsing bytes+metadata into one atomic putAttachment is the natural home for:

  • Bare-bytes orphan window — bytes stored with no metadata record, from a crash between the two old steps. One atomic operation removes the window (docs/design-assessment-2026-07.md §B2 context, §Garbage collection).
  • F3 — concurrent first-upload mimeType race: with a single combined path, guard it in one place (adapter transaction, per the deleteUnreferencedAttachmentRecords idiom).
  • E1ScopedStack.putAttachment() stamps entityId on owner uploads (stack.ts:2132-2146), violating the Polish batch: owner entityId stamping, unused timezone, lenient date validation, silent null content filter #69 "owner writes carry no entityId" invariant. Fix while in this method: apply the same isOwner ? undefined : requester normalization ScopedStack.create() already uses.

Work items

  • ScopedStack.create(): refuse _attachment@1 (by baseId) for non-owners, with the readable-referencing-record carve-out (residual decision 1)
  • ScopedStack.putAttachment(): normalize owner uploads to no entityId (E1)
  • checkAttachmentMimeTypeOnCreate: generic conflict message (anti-oracle stopgap)
  • Spec §Attachments + §API Adapter Wire Format: POST /attachments now carries mimeType/filename and creates the _attachment@1 record; generic POST /records for _attachment@1 is owner-only; document the possession-by-byte-derivation rule and the non-owner path
  • APIAdapter: keep sending Content-Type/Content-Disposition on upload; parse the returned metadata record from POST /attachments; ensure a non-owner create('_attachment@1', …) surfaces the 403 cleanly
  • Fold bytes+metadata into one atomic operation; close the bare-bytes-orphan window and guard the F3 mimeType race in the same place
  • @haverstack/conformance-fixtures: add fixtures pinning (a) POST /attachments creating the metadata record, (b) non-owner POST /records for _attachment@1 → 403, (c) the generic anti-oracle error shape

Tests

  • Non-owner create('_attachment@1', { fileId: <guessed> })StackPermissionError; owner (and unscoped Stack) still succeeds
  • Exploit regression: non-owner cannot getAttachment() a file they never uploaded and that no readable record references — even after attempting the guessed-fileId metadata create
  • Non-owner putAttachment(bytes, mime, filename) still works end-to-end (upload → the file is now accessible to them → can reference it)
  • Carve-out: non-owner with a readable referencing record can add a second _attachment@1 (different filename) without re-uploading; a non-owner without one cannot
  • Anti-oracle: guessed-fileId mimeType conflict returns a generic error indistinguishable from the not-found case
  • E1: owner uploading via asEntity(ownerEntityId).putAttachment() produces a record with no entityId
  • Bare-bytes orphan window closed: no interruptible gap between bytes and metadata

Refs

#51 (reference-creation gating + anti-oracle rule this restores), #65 (mimeType-as-fileId-property, the conflict-check being hardened), #69 (owner-writes-carry-no-entityId, E1), #57 (change-wire-in-place policy), #64 (bare-bytes orphans / GC), and docs/design-assessment-2026-07.md §A1/§B2/§E1/§F3 (PR #105).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions