Skip to content

Commit e0b049a

Browse files
os-zhuangclaude
andauthored
fix(security): enforce static readonly fields on the UPDATE write path (#2948) (#2957)
A field's static `readonly: true` was never enforced server-side on update: the record validator only skipped read-only columns from validation, and only the conditional `readonlyWhen` variant was stripped from the write payload. A non-system update could therefore overwrite any readonly column — audit stamps, provenance, or other system-computed values. (#2946 already closed the cross-tenant organization_id face; this is the broader in-tenant integrity face.) engine.update now strips caller-supplied writes to statically-readonly fields for non-system contexts, on both the single-id and multi-row paths (strips, does not reject — symmetric with readonlyWhen). Two guards keep legit writes intact: - caller-supplied only: the strip runs against a snapshot of the keys the caller sent BEFORE hooks/middleware ran, so audit-hook stamps (updated_by/ updated_at) and write-middleware stamps survive; only a client that forged a readonly field has it dropped. - system-context exempt: isSystem writes (import, seed replay, approvals, lifecycle) legitimately set readonly columns and skip the strip. Adds a stripReadonlyFields helper + unit tests and an engine-level integration test (user forge stripped, server stamp survives, system write allowed). objectql suite: 852 passed. Claude-Session: https://claude.ai/code/session_019QRUvVfpvSycAHMMF2xTxs Co-authored-by: Claude <noreply@anthropic.com>
1 parent c11e24b commit e0b049a

5 files changed

Lines changed: 198 additions & 1 deletion

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/objectql': minor
3+
---
4+
5+
fix(security): enforce static `readonly` fields on the UPDATE write path (#2948)
6+
7+
A field's static `readonly: true` was never enforced server-side on update: the
8+
record validator only *skipped* read-only columns from validation, and only the
9+
conditional `readonlyWhen` variant was stripped from the write payload. A
10+
non-system (user-context) update could therefore overwrite any `readonly`
11+
column — audit stamps, provenance (`managed_by`), or other system-computed
12+
values — unless a field-level permission happened to guard it. (The
13+
cross-tenant `organization_id` face was already closed by #2946; this is the
14+
broader in-tenant integrity face.)
15+
16+
`engine.update` now strips **caller-supplied** writes to statically-`readonly`
17+
fields for non-system contexts, on both the single-id and multi-row paths
18+
(symmetric with `readonlyWhen` — it strips, does not reject). Two guards keep
19+
every legitimate write intact:
20+
21+
- **caller-supplied only** — the strip runs against a snapshot of the keys the
22+
caller sent *before* hooks/middleware ran, so server stamps applied by the
23+
audit hook (`updated_by`/`updated_at`) and write middleware survive; only a
24+
client that explicitly forged a read-only field has it dropped.
25+
- **system-context exempt**`isSystem` writes (import, seed replay, approvals,
26+
lifecycle hooks) legitimately set read-only columns and skip the strip.
27+
28+
No change for single-org or any write that does not forge a read-only column.

packages/objectql/src/engine.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import type { Expression } from '@objectstack/spec';
3131
import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec';
3232
import { bindHooksToEngine } from './hook-binder.js';
3333
import { validateRecord, normalizeMultiValueFields, coerceBooleanFields } from './validation/record-validator.js';
34-
import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields } from './validation/rule-validator.js';
34+
import { evaluateValidationRules, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyFields } from './validation/rule-validator.js';
3535
import { applyInMemoryAggregation } from './in-memory-aggregation.js';
3636

3737
interface FormulaPlanEntry { name: string; expression: Expression; }
@@ -2357,6 +2357,14 @@ export class ObjectQL implements IDataEngine {
23572357
context: options?.context,
23582358
};
23592359

2360+
// [#2948] Snapshot the keys the CALLER supplied, BEFORE any middleware /
2361+
// beforeUpdate hook stamps server-managed columns (owner/tenant stamp,
2362+
// `updated_by`/`updated_at`). The static-`readonly` strip below drops only
2363+
// caller-supplied read-only writes, so hook/middleware stamps survive.
2364+
const suppliedKeys: ReadonlySet<string> = new Set(
2365+
Object.keys((opCtx.data ?? {}) as Record<string, unknown>),
2366+
);
2367+
23602368
await this.executeWithMiddleware(opCtx, async () => {
23612369
const hookContext: HookContext = {
23622370
object,
@@ -2395,6 +2403,14 @@ export class ObjectQL implements IDataEngine {
23952403
// field is read-only for this record's state, so the incoming
23962404
// change is ignored (the persisted value is kept).
23972405
hookContext.input.data = stripReadonlyWhenFields(updateSchema as any, hookContext.input.data as Record<string, unknown>, priorRecord, this.logger) as any;
2406+
// [#2948] Enforce STATIC `readonly` on the write path for
2407+
// non-system callers (system writes legitimately set read-only
2408+
// columns and are exempt). Runs AFTER hooks/middleware stamped
2409+
// their columns; `suppliedKeys` ensures only caller-forged
2410+
// read-only writes are dropped, never the server stamps.
2411+
if (!opCtx.context?.isSystem) {
2412+
hookContext.input.data = stripReadonlyFields(updateSchema as any, hookContext.input.data as Record<string, unknown>, suppliedKeys, this.logger) as any;
2413+
}
23982414
evaluateValidationRules(updateSchema as any, hookContext.input.data as Record<string, unknown>, 'update', { previous: priorRecord, logger: this.logger, currentUser: this.buildEvalUser(opCtx.context) });
23992415
result = await driver.update(object, hookContext.input.id as string, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
24002416
} else if (options?.multi && driver.updateMany) {
@@ -2407,6 +2423,13 @@ export class ObjectQL implements IDataEngine {
24072423
if (needsPriorRecord(updateSchema as any)) {
24082424
this.logger.warn('Object-level validation rules (state_machine/cross_field/script) are not enforced on multi-row updates', { object });
24092425
}
2426+
// [#2948] Same static-`readonly` write guard on the bulk path —
2427+
// a forged read-only column in a multi-row update is dropped for
2428+
// non-system callers (a foreign `organization_id` is additionally
2429+
// rejected upstream by the tenant write wall, #2946).
2430+
if (!opCtx.context?.isSystem) {
2431+
hookContext.input.data = stripReadonlyFields(updateSchema as any, hookContext.input.data as Record<string, unknown>, suppliedKeys, this.logger) as any;
2432+
}
24102433
const ast: QueryAST = { object, where: options.where };
24112434
result = await driver.updateMany(object, ast, hookContext.input.data as Record<string, unknown>, hookContext.input.options as any);
24122435
} else {

packages/objectql/src/plugin.integration.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,4 +1262,64 @@ describe('ObjectQLPlugin - Metadata Service Integration', () => {
12621262
expect(row.tenant_id).toBe('org-7');
12631263
});
12641264
});
1265+
1266+
// #2948 — static `readonly:true` fields must not be writable via a
1267+
// NON-system UPDATE. The strip is caller-supplied-only, so server stamps
1268+
// (updated_by) written by the audit hook survive; system context is exempt.
1269+
describe('Static readonly write enforcement on UPDATE (#2948)', () => {
1270+
async function bootWithCapture() {
1271+
const updates: Record<string, any>[] = [];
1272+
const mockDriver = {
1273+
name: 'ro-capture', version: '1.0.0',
1274+
connect: async () => {}, disconnect: async () => {},
1275+
find: async () => [], findOne: async () => null,
1276+
create: async (_o: string, d: any) => ({ id: 'rec-1', ...d }),
1277+
update: async (_o: string, _i: any, d: any) => { updates.push({ ...d }); return { id: _i, ...d }; },
1278+
delete: async () => true, syncSchema: async () => {},
1279+
};
1280+
await kernel.use({
1281+
name: 'ro-capture-plugin', type: 'driver', version: '1.0.0',
1282+
init: async (ctx) => { ctx.registerService('driver.ro-capture', mockDriver); },
1283+
});
1284+
await kernel.use(new ObjectQLPlugin());
1285+
await kernel.bootstrap();
1286+
const objectql = kernel.getService('objectql') as any;
1287+
const obj: ObjectSchema = {
1288+
name: 'ro_obj', label: 'RO Obj', datasource: 'ro-capture',
1289+
fields: {
1290+
name: { name: 'name', label: 'Name', type: 'text' },
1291+
// a statically read-only business column (e.g. a provenance stamp)
1292+
locked: { name: 'locked', label: 'Locked', type: 'text', readonly: true } as any,
1293+
updated_by: { name: 'updated_by', label: 'Updated By', type: 'text', readonly: true } as any,
1294+
},
1295+
};
1296+
objectql.registry.registerObject(obj, 'test', 'test');
1297+
return { objectql, updates };
1298+
}
1299+
1300+
it('drops a user-context write to a static readonly field, but keeps the server updated_by stamp', async () => {
1301+
const { objectql, updates } = await bootWithCapture();
1302+
await objectql.update(
1303+
'ro_obj',
1304+
{ id: 'rec-1', name: 'new-name', locked: 'attacker-value' },
1305+
{ context: { userId: 'user-9', tenantId: 'org-1' } },
1306+
);
1307+
expect(updates.length).toBe(1);
1308+
const data = updates[0];
1309+
expect(data.name).toBe('new-name'); // editable field written
1310+
expect(data).not.toHaveProperty('locked'); // readonly forge stripped
1311+
expect(data.updated_by).toBe('user-9'); // server stamp survived
1312+
});
1313+
1314+
it('ALLOWS a system-context write to the same static readonly field', async () => {
1315+
const { objectql, updates } = await bootWithCapture();
1316+
await objectql.update(
1317+
'ro_obj',
1318+
{ id: 'rec-1', locked: 'legitimate-migration-value' },
1319+
{ context: { isSystem: true } },
1320+
);
1321+
expect(updates.length).toBe(1);
1322+
expect(updates[0].locked).toBe('legitimate-migration-value');
1323+
});
1324+
});
12651325
});

packages/objectql/src/validation/rule-validator.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
needsPriorRecord,
77
legalNextStates,
88
stripReadonlyWhenFields,
9+
stripReadonlyFields,
910
} from './rule-validator.js';
1011
import { ValidationError } from './record-validator.js';
1112

@@ -56,6 +57,46 @@ describe('stripReadonlyWhenFields (B2)', () => {
5657
});
5758
});
5859

60+
// #2948 — static `readonly:true` write enforcement (caller-supplied only).
61+
const stampedFields = {
62+
fields: {
63+
title: { type: 'text' },
64+
created_by: { type: 'lookup', readonly: true },
65+
updated_by: { type: 'lookup', readonly: true },
66+
},
67+
};
68+
69+
describe('stripReadonlyFields (#2948)', () => {
70+
it('drops a caller-supplied write to a static readonly field', () => {
71+
const supplied = new Set(['title', 'created_by']);
72+
const out = stripReadonlyFields(stampedFields, { title: 'x', created_by: 'attacker' }, supplied);
73+
expect(out).toEqual({ title: 'x' });
74+
});
75+
76+
it('KEEPS a readonly field the caller did NOT supply (server stamp survives)', () => {
77+
// `updated_by` was written into `data` by the audit-stamp hook, not the
78+
// caller — it must not be stripped.
79+
const supplied = new Set(['title']);
80+
const out = stripReadonlyFields(stampedFields, { title: 'x', updated_by: 'u1' }, supplied);
81+
expect(out).toEqual({ title: 'x', updated_by: 'u1' });
82+
});
83+
84+
it('returns the SAME object when nothing is stripped', () => {
85+
const d = { title: 'x' };
86+
expect(stripReadonlyFields(stampedFields, d, new Set(['title']))).toBe(d);
87+
});
88+
89+
it('drops a caller-forged readonly field even when it also carries a server stamp key', () => {
90+
const supplied = new Set(['title', 'created_by']);
91+
const out = stripReadonlyFields(
92+
stampedFields,
93+
{ title: 'x', created_by: 'attacker', updated_by: 'u1' },
94+
supplied,
95+
);
96+
expect(out).toEqual({ title: 'x', updated_by: 'u1' });
97+
});
98+
});
99+
59100
describe('needsPriorRecord — field conditional rules (B2)', () => {
60101
it('is true when a field declares requiredWhen / readonlyWhen', () => {
61102
expect(needsPriorRecord(invoiceFields as any)).toBe(true);

packages/objectql/src/validation/rule-validator.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,49 @@ export function stripReadonlyWhenFields(
200200
return result;
201201
}
202202

203+
/**
204+
* Strip CALLER-SUPPLIED writes to statically `readonly: true` fields from an
205+
* UPDATE payload (#2948). Unlike `readonlyWhen` (conditional, handled above), a
206+
* static `readonly` field was never enforced on the server write path: the
207+
* record validator only SKIPS it from validation, so a user-context update
208+
* could overwrite audit stamps, provenance, or any other read-only column. We
209+
* STRIP the change (symmetric with `readonlyWhen`) rather than reject it, for
210+
* compatibility.
211+
*
212+
* Two guards keep every legitimate write intact:
213+
* - `suppliedKeys` — only keys the CALLER sent are candidates. Server stamps
214+
* applied by beforeUpdate hooks or write middleware (e.g. `updated_by` /
215+
* `updated_at`, plugin.ts) land in `data` but are NOT in `suppliedKeys`, so
216+
* they survive. A caller that *explicitly* forges e.g. `updated_by` simply
217+
* has it dropped for that request (the last-modified stamp is left unchanged
218+
* — safe).
219+
* - system context — the caller passes this strip only for NON-system writes;
220+
* system-context writes (import, seed replay, approvals, lifecycle hooks —
221+
* all `isSystem: true`) legitimately set read-only columns and skip it.
222+
*
223+
* Returns the same object when nothing is stripped, else a shallow copy with the
224+
* offending keys removed.
225+
*/
226+
export function stripReadonlyFields(
227+
objectSchema: { fields?: Record<string, ConditionalFieldDef> } | undefined | null,
228+
data: Record<string, unknown> | undefined | null,
229+
suppliedKeys: ReadonlySet<string>,
230+
logger?: EvaluateRulesOptions['logger'],
231+
): Record<string, unknown> | undefined | null {
232+
const fields = objectSchema?.fields;
233+
if (!fields || !data) return data;
234+
let result = data;
235+
for (const [name, def] of Object.entries(fields)) {
236+
if (!def?.readonly) continue;
237+
if (!(name in (result as Record<string, unknown>))) continue;
238+
if (!suppliedKeys.has(name)) continue; // server-stamped, not caller-supplied — keep
239+
if (result === data) result = { ...data };
240+
delete (result as Record<string, unknown>)[name];
241+
logger?.warn?.(`Field '${name}' is read-only — ignoring incoming change (#2948)`);
242+
}
243+
return result;
244+
}
245+
203246
/**
204247
* A rule needs the prior record if it reasons about the transition or compares
205248
* against unchanged fields (`state_machine` / `cross_field` / `script`), or if
@@ -233,6 +276,8 @@ interface ConditionalFieldDef {
233276
requiredWhen?: string | Expression;
234277
conditionalRequired?: string | Expression; // back-compat alias of requiredWhen
235278
readonlyWhen?: string | Expression;
279+
/** Static, unconditional read-only flag (`field.readonly`). #2948. */
280+
readonly?: boolean;
236281
/** Field type — scopes per-option `visibleWhen` enforcement to choice fields. */
237282
type?: string;
238283
/** Select/multiselect/radio options; an option may gate itself with `visibleWhen`. */

0 commit comments

Comments
 (0)