Skip to content

plugin-sharing's SINGLE-record write gates still read the phantom owner_id on federated objects (the three hasOwnerField consumers #7858 did not touch) #8119

Description

@os-zhuang

Found while implementing #7858. Filed unassigned, and deliberately not fixed in that PR: #7858's scope was ruled to be the two bulk filters, and the paths below fail closed, so widening them is a security-relevant change that deserves its own analysis rather than a rider.

What #7858 fixed, and what it did not

hasOwnerField (packages/plugins/plugin-sharing/src/sharing-service.ts:141) has five consumers. #7858 guarded two of them — buildReadFilter and buildWriteFilter — with a provenance test: an owner_id byte-identical to the shipped OWNER_FIELD_DEF on an external object is the registry's injected anchor, not a real column, so ownership scoping contributes nothing.

The other three still gate on the raw field-existence answer:

  • checkEdit (:549 pre-PR numbering)
  • checkDelete (:631)
  • assertSharingEnabled (:872)

The code path

checkEdit / checkDelete both reach the shared ownership fast-path matchesOwnerScope, which selects the phantom column straight off the remote table:

const own = await this.engine.find(object, {
  where: { id: recordId },
  fields: ['id', OWNER_FIELD],   // OWNER_FIELD = 'owner_id'
  limit: 1,
  context: SYSTEM_CTX,
});
const owner = Array.isArray(own) && own[0] ? (own[0] as any)[OWNER_FIELD] : undefined;
if (owner == null) return false;

On a federated object the platform provisions no storage (Engine.syncObjectSchema returns early for external != null), so owner_id is not in the remote table. The ownership fast-path can therefore never admit: either the driver raises and the try/catch routes to writeGateFailClosed, or the value comes back absent and matchesOwnerScope returns false. Both land on deny once the share-grant and modifyAllRecords branches also miss.

assertSharingEnabled is the mirror image: it currently reports a phantom-anchor federated object as share-able (hasOwnerField is true), so a share row can be minted on an object whose gates can never consult it.

Why this is filed rather than fixed

The direction is opposite to #7858's. There, the phantom predicate made a readable object unreadable; here the same phantom column makes the gates refuse, which is fail-closed and safe. Flipping checkEdit / checkDelete to abstain would hand the decision to other layers and could turn a refusal into an allow — that needs deciding, not guessing. assertSharingEnabled's answer is a separate question again (refusing to mint a useless share row is arguably the improvement).

Not measured

⚠️ This is a code-path reading, not a boot measurement — unlike #7858's body, which carried real filter output from a booted stack. I did not run a federated single-record write. The dialect behaviour of selecting a nonexistent column in the SELECT list (as opposed to #7858's comparison-position degradation on SQLite) is specifically unverified. Worth measuring before acting.

Related


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions