Skip to content

Commit dee7feb

Browse files
os-zhuangclaude
andauthored
fix(security): scope bulk-write predicate guard to caller filter + dedupe pre-image reads (#3053)
Two hardening follow-ups from the #3018 adversarial review. ① The step-2.9 anti-oracle predicate guard is now middleware-order-independent for writes. #2982 made bulk update/delete carry opCtx.ast, first bringing them under the guard; it inspected opCtx.ast.where, which plugin-sharing (a sibling middleware of unguaranteed order) may already have composed an owner_id match into — so on an FLS-hidden-owner_id object a legitimate bulk write could 403. The guard now inspects opCtx.options.where (the caller's untouched predicate) for update/delete. Reads unchanged. ② Pre-image read dedup: a single fail-closed readRowById helper backs the provenance gates, and a memoized getCallerPreImage collapses the owner-anchor echo (3.5) and RLS check post-image (3.6) — the identical (object, id, caller-context) row — into one read per operation. Pure refactor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2973f7f commit dee7feb

3 files changed

Lines changed: 134 additions & 25 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(security): scope the bulk-write predicate guard to the caller's own filter, and dedupe pre-image reads (#3018 review follow-ups)
6+
7+
Two hardening follow-ups from the #3018 adversarial review.
8+
9+
**Predicate guard is now middleware-order-independent for writes.** #2982 made bulk
10+
`update`/`delete` carry an `opCtx.ast`, which brought them under the step-2.9
11+
anti-oracle predicate guard for the first time. That guard is documented to run
12+
against the *caller's own* predicate — RLS / sharing filters legitimately reference
13+
fields the caller cannot read (e.g. `owner_id`). But for a bulk write it inspected
14+
`opCtx.ast.where`, which a sibling middleware (`plugin-sharing`) may have already had
15+
an `owner_id` owner-match composed into — and the two middlewares' registration order
16+
is not contractually guaranteed. On an object whose `owner_id` is FLS-hidden, that
17+
could 403 a legitimate bulk write purely because the injected filter named the field.
18+
The guard now inspects `opCtx.options.where` (the caller's untouched predicate) for
19+
`update`/`delete`, so it can never mistake an injected owner/RLS filter for a caller
20+
probe, independent of middleware order. Reads are unchanged (the read seed is the
21+
caller's query verbatim and the guard runs before this middleware's own injection).
22+
23+
**Pre-image reads deduplicated.** The by-id "read the target row" pattern was inlined
24+
at ~5 gates with slightly divergent shapes; a single `readRowById` helper (fail-closed:
25+
missing engine / null id / thrown read → `null`, which always denies) now backs the
26+
provenance gates, and a memoized `getCallerPreImage` collapses the owner-anchor echo
27+
check (3.5) and the RLS `check` post-image (3.6) — which read the identical
28+
`(object, id, caller-context)` row — into one read per operation. No behavior change;
29+
the read shape can no longer drift across sites.

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,6 +1223,48 @@ describe('SecurityPlugin', () => {
12231223
});
12241224
});
12251225

1226+
it('[#2982 follow-up] bulk-write predicate guard inspects the CALLER predicate, not injected owner/RLS filters', async () => {
1227+
// The anti-oracle guard (2.9) must reject a caller filtering on an
1228+
// FLS-hidden field, but must NOT reject an owner_id predicate a sibling
1229+
// middleware (plugin-sharing) composed onto opCtx.ast — otherwise a bulk
1230+
// write on an object whose owner_id is FLS-hidden 403s purely because the
1231+
// injected filter mentions it, and the result depends on middleware order.
1232+
const ownerHiddenSet: PermissionSet = {
1233+
name: 'member_default',
1234+
label: 'Member',
1235+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
1236+
fields: { 'task.owner_id': { readable: false, editable: false }, 'task.ssn': { readable: false, editable: false } },
1237+
} as any;
1238+
const boot = async () => {
1239+
const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' });
1240+
const harness = makeMiddlewareCtx({ permissionSets: [ownerHiddenSet], objectFields: ['id', 'owner_id', 'name', 'ssn', 'status'] });
1241+
await plugin.init(harness.ctx);
1242+
await plugin.start(harness.ctx);
1243+
return harness;
1244+
};
1245+
const ctx = { userId: 'u1', tenantId: 'org-1', positions: [], permissions: ['member_default'] };
1246+
1247+
// Injected owner_id in the AST, but the caller's own predicate is clean → allowed.
1248+
const injected: any = await boot();
1249+
const okCtx: any = {
1250+
object: 'task', operation: 'update', data: { name: 'renamed' },
1251+
options: { where: { status: 'open' }, multi: true },
1252+
ast: { where: { $and: [{ status: 'open' }, { owner_id: 'u1' }] } }, // as plugin-sharing would compose
1253+
context: ctx,
1254+
};
1255+
await injected.run(okCtx); // must NOT throw — owner_id is only in the injected filter
1256+
1257+
// The caller's OWN predicate references a hidden field → still rejected.
1258+
const probing: any = await boot();
1259+
const denyCtx: any = {
1260+
object: 'task', operation: 'update', data: { name: 'renamed' },
1261+
options: { where: { ssn: 'guess' }, multi: true },
1262+
ast: { where: { ssn: 'guess' } },
1263+
context: ctx,
1264+
};
1265+
await expect(probing.run(denyCtx)).rejects.toThrow(/filter oracle|not readable/);
1266+
});
1267+
12261268
it('FLS write — the record echoed back by an update is masked (no read-leak of hidden fields)', async () => {
12271269
// Regression: a caller with edit-but-not-field-read must not be able to
12281270
// read a read-protected field back out of the mutation response. The write

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 63 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,7 +1195,7 @@ export class SecurityPlugin implements Plugin {
11951195
// closes the owner-enumeration oracle a system read would
11961196
// open (a caller who can't read the row gets null → deny,
11971197
// indistinguishable from a non-owner).
1198-
const pre: any = await this.ql.findOne(opCtx.object, { where: { id: targetId }, context: opCtx.context });
1198+
const pre = await this.getCallerPreImage(opCtx, targetId);
11991199
unchanged = !!pre && pre.owner_id != null && String(pre.owner_id) === String(row.owner_id);
12001200
} catch {
12011201
unchanged = false; // fail closed
@@ -1257,13 +1257,10 @@ export class SecurityPlugin implements Plugin {
12571257
);
12581258
postImage = null;
12591259
} else if (this.ql) {
1260-
let pre: any = null;
1261-
try {
1262-
pre = await this.ql.findOne(opCtx.object, { where: { id: targetId }, context: opCtx.context });
1263-
} catch {
1264-
pre = null;
1265-
}
1266-
if (pre && typeof pre === 'object') postImage = { ...(pre as Record<string, unknown>), ...(opCtx.data as Record<string, unknown>) };
1260+
// Shares the memoized caller pre-image with the step-3.5 owner
1261+
// echo check — the identical (object, id, caller-context) row.
1262+
const pre = await this.getCallerPreImage(opCtx, targetId);
1263+
if (pre) postImage = { ...pre, ...(opCtx.data as Record<string, unknown>) };
12671264
}
12681265
}
12691266
if (postImage && !checkParts.every((f) => matchesFilterCondition(postImage as any, f as any))) {
@@ -1366,9 +1363,10 @@ export class SecurityPlugin implements Plugin {
13661363
// sorting / grouping on it (row presence is the oracle; the objectui
13671364
// /data surface makes URL-driven predicates first-class). Reject such
13681365
// queries outright — silent predicate dropping would change query
1369-
// semantics unpredictably. MUST run against the CALLER's AST, before
1370-
// the RLS injection below: RLS policies legitimately reference fields
1371-
// the caller cannot read (e.g. owner_id).
1366+
// semantics unpredictably. MUST run against the CALLER's OWN predicate,
1367+
// before the RLS injection below: RLS / sharing filters legitimately
1368+
// reference fields the caller cannot read (e.g. owner_id) and must not be
1369+
// rejected.
13721370
if (opCtx.ast) {
13731371
let guardPerms = this.permissionEvaluator.getFieldPermissions(opCtx.object, permissionSets);
13741372
guardPerms = this.foldFieldRequiredPermissions(guardPerms, secMeta.fieldRequiredPermissions, permissionSets);
@@ -1380,7 +1378,22 @@ export class SecurityPlugin implements Plugin {
13801378
guardPerms = intersectFieldMasks(guardPerms, delGuard);
13811379
}
13821380
if (Object.keys(guardPerms).length > 0) {
1383-
assertReadableQueryFields(opCtx.ast as unknown as Record<string, unknown>, guardPerms, opCtx.object);
1381+
// [#2982 follow-up] For a bulk WRITE the caller's own predicate is
1382+
// `opCtx.options.where` (untouched); `opCtx.ast.where` may ALREADY
1383+
// carry an owner-match that plugin-sharing's write branch composed in
1384+
// — and plugin-sharing is a SIBLING middleware whose registration
1385+
// order relative to this one is not guaranteed. Guarding the injected
1386+
// AST would 403 a legitimate bulk write on an object whose owner_id is
1387+
// FLS-hidden, purely because a sibling filter mentioned it. Inspect
1388+
// the caller's own predicate so the guard is independent of middleware
1389+
// order and never mistakes an injected filter for a probe. Reads keep
1390+
// guarding the ast: the read seed is the caller's query verbatim and
1391+
// this middleware runs before its own RLS injection.
1392+
const guardTarget: Record<string, unknown> =
1393+
opCtx.operation === 'update' || opCtx.operation === 'delete'
1394+
? { where: opCtx.options?.where }
1395+
: (opCtx.ast as unknown as Record<string, unknown>);
1396+
assertReadableQueryFields(guardTarget, guardPerms, opCtx.object);
13841397
}
13851398
}
13861399

@@ -2156,9 +2169,7 @@ export class SecurityPlugin implements Plugin {
21562169
return;
21572170
}
21582171

2159-
const existing = await this.ql
2160-
.findOne('sys_permission_set', { where: { id: targetId }, context: { isSystem: true } })
2161-
.catch(() => null);
2172+
const existing = await this.readRowById('sys_permission_set', targetId, { isSystem: true });
21622173
if (existing && (existing as Record<string, unknown>).managed_by === 'package') {
21632174
const row = existing as Record<string, unknown>;
21642175
throw new PermissionDeniedError(
@@ -2264,9 +2275,7 @@ export class SecurityPlugin implements Plugin {
22642275
return;
22652276
}
22662277

2267-
const existing = await this.ql
2268-
.findOne(opCtx.object, { where: { id: targetId }, context: { isSystem: true } })
2269-
.catch(() => null);
2278+
const existing = await this.readRowById(opCtx.object, targetId, { isSystem: true });
22702279
const existingManagedBy = existing
22712280
? String((existing as Record<string, unknown>).managed_by ?? '')
22722281
: '';
@@ -2297,6 +2306,40 @@ export class SecurityPlugin implements Plugin {
22972306
return null;
22982307
}
22992308

2309+
/**
2310+
* By-id row read with the standard FAIL-CLOSED contract (missing engine,
2311+
* null id, or a thrown read → `null`), shared by every provenance / pre-image
2312+
* gate. Centralising the read SHAPE keeps a future change (soft-delete filter,
2313+
* field projection) from drifting across the ~5 call sites that used to
2314+
* inline it (#3018 review — Reuse). A `null` return always DENIES downstream;
2315+
* it never bypasses a gate.
2316+
*/
2317+
private async readRowById(object: string, id: unknown, context: any): Promise<Record<string, unknown> | null> {
2318+
if (id == null || typeof this.ql?.findOne !== 'function') return null;
2319+
try {
2320+
const row = await this.ql.findOne(object, { where: { id }, context });
2321+
return row && typeof row === 'object' ? (row as Record<string, unknown>) : null;
2322+
} catch {
2323+
return null;
2324+
}
2325+
}
2326+
2327+
/**
2328+
* The single-id write PRE-IMAGE read under the CALLER's context, memoized per
2329+
* operation. The owner-anchor echo check (step 3.5) and the RLS `check`
2330+
* post-image (step 3.6) read the IDENTICAL `(object, id, caller-context)` row;
2331+
* this collapses the two reads into one. Safe: no write to the row happens
2332+
* between the gates (the driver write runs after the whole middleware pass),
2333+
* so the pre-image is stable within the operation.
2334+
*/
2335+
private async getCallerPreImage(opCtx: any, id: unknown): Promise<Record<string, unknown> | null> {
2336+
if (id == null) return null;
2337+
if (opCtx.__preImage && opCtx.__preImage.id === id) return opCtx.__preImage.row;
2338+
const row = await this.readRowById(opCtx.object, id, opCtx.context);
2339+
opCtx.__preImage = { id, row };
2340+
return row;
2341+
}
2342+
23002343
/**
23012344
* [ADR-0095 D1] Compute the effective row filter for (object, operation) as
23022345
* `Layer0(tenant) AND Layer1(business RLS)`.
@@ -2597,14 +2640,9 @@ export class SecurityPlugin implements Plugin {
25972640
} else {
25982641
const targetId = this.extractSingleId(opCtx);
25992642
if (targetId == null) return; // bulk write — scoped by the read filter on the AST
2600-
let row: any = null;
2601-
try {
2602-
row = await this.ql.findOne(object, { where: { id: targetId }, context: { isSystem: true } });
2603-
} catch {
2604-
row = null;
2605-
}
2643+
const row = await this.readRowById(object, targetId, { isSystem: true });
26062644
if (!row) deny('target record not found', targetId);
2607-
masterId = row[rel!.fk];
2645+
masterId = row![rel!.fk];
26082646
}
26092647
if (masterId == null) deny('detail record has no master reference');
26102648

0 commit comments

Comments
 (0)