Skip to content

ScopedStack.create() passes caller options through unchecked — attachment-association escalation, parent/app spoofing #51

Description

@cuibonobo

Problem

ScopedStack.create() forces entityId to the requester but forwards every other option untouched:

return this.stack.create(typeId, content, { ...opts, entityId: requester });

A requester whose only authorization is a create grant on one type gets to set permissions, parentId, appId, and associations on records written into the owner's stack. One of these is a genuine privilege escalation; the rest are integrity/attribution problems.

The escalation: attachment associations grant file access

Attachment access is reference-implies-access: ScopedStack.getAttachment(fileId) succeeds if the requester can read any record carrying an attachment association for that fileId. But nothing gates the creation of such references on having access to the file being referenced.

Walkthrough, using the spec's own example default grant (['create', 'read-own'] on some type):

  1. Requester learns a private file's fileId out-of-band (or from any leak — fileIds are SHA-256 content hashes, so they're also guessable for low-entropy content).
  2. scopedStack.create(typeId, content, { associations: [{ kind: 'attachment', label: 'x', fileId }] }) — allowed, because only the create grant is checked.
  3. The requester can read their own record via read-owngetAttachment(fileId) finds a readable referencing record → bytes disclosed.

The same hole exists post-create for anyone holding an update-own grant: associate() checks write access to the record, never access to the file being attached. The root cause is the asymmetry: access flows from references, but reference creation is ungated.

The content-addressing wrinkle makes this worse than it looks: because fileId is a hash of the bytes, step 3 doubles as a confirmation oracle — attach a guessed hash and try to read it, and success confirms the stack contains those exact bytes (e.g. a specific document).

Field-by-field

Option Risk Proposed handling
associations (attachment kind) Escalation — file access + content-confirmation oracle (above) Gate on file access (below)
associations (relationship kind) Hygiene — pollutes relatedTo reverse-queries on records the requester can't read; spam/planted references surfacing in the owner's views Require read access to the referenced record
associations (tag kind) None Allow
parentId Integrity — inject records into arbitrary hierarchies; surfaces in the owner's parentId queries under trees the requester knows nothing about Require read access to the parent
appId Attribution spoofing — claim to be any app Document as untrusted (see note)
permissions Policy question, not strictly an escalation — ScopedStack.setPermissions() already lets the record creator set permissions post-create, so create-time is consistent with that. But it means a contributor can make content public, or grant an accomplice write, inside the owner's stack Keep consistent with setPermissions; surface the policy question

Proposed changes

1. Gate attachment references on file access — at every reference-creation point

Define a single predicate, used by ScopedStack.create() (create-time associations) and ScopedStack.associate():

A requester may attach fileId to a record iff they are the owner, they uploaded the file (own an _attachment@1 record for it), or they can already read some record referencing it.

This is exactly the existing getAttachment() access rule — reference creation should require what reference possession grants. Failing the check throws StackPermissionError.

Anti-oracle detail: the error for "file doesn't exist" and "file exists but you can't reference it" must be indistinguishable, or the check itself becomes the confirmation oracle described above.

2. Gate parentId and relationship targets on read access

Same shape: creating a record under parentId, or a relationship association to recordId, requires the requester can read the referent. Same anti-oracle rule: missing and unreadable referents produce the same error. (Today Stack.create() doesn't validate parentId existence at all — a separate small gap this would subsume for the scoped path.)

3. appId: document as self-reported

App identity has no verification mechanism yet (the spec already notes _app records are only "a foundation for future enforcement in the API adapter"). Stripping it from scoped creates would break legitimate multi-app attribution, so: keep it, but the spec's Permissions section should state plainly that appId is untrusted metadata — never a permission input. Verified app identity is future work (and orthogonal to entity identity, #49).

4. permissions at create: keep, but state the policy

Aligning create-time with the existing setPermissions policy (creator may set permissions on their own record) is coherent — but the spec should say explicitly that this is deliberate: a contributor can widen access to records they author in your stack, up to and including public. If that's not wanted, the alternative is restricting scoped setPermissions/create-time permissions to targets the owner has whitelisted — noted as an open question rather than proposed, since it adds real machinery.

5. Tests

  • Create-with-attachment-association by a grantee without file access → StackPermissionError, and the error matches the nonexistent-file case byte-for-byte.
  • associate() attachment gating for update-own holders.
  • parentId / relationship-target read checks, including the missing-vs-forbidden indistinguishability.
  • Regression: owner and uploader flows unaffected; tag associations unaffected.

Non-goals

Depends on the uploader-check pagination fix in #50 (the "uploaded the file themselves" predicate must scan all pages, or the gate itself false-denies). Refs #45 (trust boundary), #49 (identity — DIDs change who entityId names, not this logic).

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