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
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):
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.
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():
constisOwner=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().thrownewStackPermissionError();}
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)
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 })iffcanAccessFile(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.
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).
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
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).
Problem
Creating an
_attachment@1metadata 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@1record with theirentityIdand thatcontent.fileId. ButScopedStack.create()applies no special check for_attachment@1, and itsfileIdis a plainstringfield (deliberately notfile-ref, so metadata records don't self-count as GC references —stack.ts:1571), sorequireFileRefAccess()never fires on it either.Exploit (two links):
creategrant on_attachment@1— i.e. every uploader, by design — creates a metadata record whosecontent.fileIdis an arbitrary guessed SHA-256.canAccessFile(), sogetAttachment(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) — plainStackis unscoped and full-trust — but the enforcement must live inScopedStack, 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@1record 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
putAttachmentis 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_groupspecial-case) for any non-owner requester — throwsStackPermissionError. The owner and unscopedStackare unchanged (full trust). Concretely, alongside the_grouphandling already increate():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 isputAttachment, 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 genericPOST /recordsfor all types toscopedStack.create()gets the 403 for free, because it delegated.Wire (to match).
POST /attachmentsstops being bytes-only: it carriesmimeType(Content-Type) andfilename(Content-Disposition) — both already sent byAPIAdapter.uploadBinarytoday and currently ignored — and the server implements it asscopedStack.putAttachment(data, mime, filename), creating bytes + metadata as one operation. GenericPOST /recordsfor_attachment@1from a non-owner returns 403 viaScopedStack. 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)
_attachment@1(e.g. a second filename) without re-sending bytes. Resolution: allow non-ownercreate('_attachment@1', { fileId: F })iffcanAccessFile(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 thecreate()guard above.putAttachmentBytes()for non-owners becomes a dead end (bytes-only, and the follow-upcreateis now refused). This is already effectively true today — a bytes-only upload leavescanAccessFilefalse, so the file can't be associated either. Resolution: no behavior change; document that non-owners use the combinedputAttachment(). KeepputAttachmentBytesfor 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 identicalStackPermissionErrorwith no distinguishing detail.Adjacent findings folded in (same PR, cheaply)
Collapsing bytes+metadata into one atomic
putAttachmentis the natural home for:docs/design-assessment-2026-07.md§B2 context, §Garbage collection).deleteUnreferencedAttachmentRecordsidiom).ScopedStack.putAttachment()stampsentityIdon 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 sameisOwner ? undefined : requesternormalizationScopedStack.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 noentityId(E1)checkAttachmentMimeTypeOnCreate: generic conflict message (anti-oracle stopgap)POST /attachmentsnow carries mimeType/filename and creates the_attachment@1record; genericPOST /recordsfor_attachment@1is owner-only; document the possession-by-byte-derivation rule and the non-owner pathAPIAdapter: keep sending Content-Type/Content-Disposition on upload; parse the returned metadata record fromPOST /attachments; ensure a non-ownercreate('_attachment@1', …)surfaces the 403 cleanly@haverstack/conformance-fixtures: add fixtures pinning (a)POST /attachmentscreating the metadata record, (b) non-ownerPOST /recordsfor_attachment@1→ 403, (c) the generic anti-oracle error shapeTests
create('_attachment@1', { fileId: <guessed> })→StackPermissionError; owner (and unscopedStack) still succeedsgetAttachment()a file they never uploaded and that no readable record references — even after attempting the guessed-fileId metadata createputAttachment(bytes, mime, filename)still works end-to-end (upload → the file is now accessible to them → can reference it)_attachment@1(different filename) without re-uploading; a non-owner without one cannotasEntity(ownerEntityId).putAttachment()produces a record with noentityIdRefs
#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).