Skip to content

Commit 99c9a41

Browse files
committed
wip(runtime): refuse a sandboxed body's write of a stored-metadata table at the seam
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1fc382c commit 99c9a41

3 files changed

Lines changed: 291 additions & 18 deletions

File tree

‎packages/runtime/src/sandbox/body-runner.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import {
5757
resolveRecordTitle,
5858
resolveRelatedTitleTarget,
5959
} from '@objectstack/objectql';
60-
import { serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
60+
import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from '../stored-metadata-reader-seam.js';
6161
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
6262
import {
6363
isWildcardHookTarget,
@@ -832,9 +832,17 @@ function buildEngineRepoFacade(ql: any, objectName: string, context?: any) {
832832
* place both body faces get their API, so the hook face, the action face and
833833
* every fallback below are served alike, and a body can copy only what it was
834834
* served.
835+
*
836+
* [#21520] And, layered over that, a body may not WRITE a stored-metadata table
837+
* at all: every write of one is refused before it runs, whatever the body's
838+
* elevation. Applied HERE and nowhere else because this is the one place a
839+
* body gets its API — a host code handler's `ctx.api` is served by the read
840+
* seam but keeps its writes (deployer code, outside the boundary).
835841
*/
836842
function buildSandboxApi(engineCtx: any, ql: any, errLabel: string) {
837-
return serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql);
843+
return refuseStoredMetadataBodyWrites(
844+
serveStoredMetadataReadsThrough(buildSandboxApiSource(engineCtx, ql, errLabel), ql),
845+
);
838846
}
839847

840848
function buildSandboxApiSource(engineCtx: any, ql: any, errLabel: string) {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21520] The write half of the stored-metadata family's boundary for
5+
* app-authored bodies: a sandboxed body may not write a family table.
6+
*
7+
* Pinned against a counting scoped-API double (it records which verb reached
8+
* it, and stores nothing), then through the real QuickJS sandbox on both body
9+
* faces. What each case asserts:
10+
*
11+
* - every write verb on each family table is refused with the boundary's
12+
* envelope (`PERMISSION_DENIED` / 403) BEFORE the verb runs;
13+
* - a predicate write is refused the same way whatever its predicate names,
14+
* so a refused write answers identically and runs nothing;
15+
* - reads still pass to the read seam, and every other object writes as
16+
* before;
17+
* - every derived context is refused the same way;
18+
* - the read seam ALONE — a host code handler's `ctx.api` — keeps its writes:
19+
* the boundary refuses bodies only.
20+
*/
21+
22+
import { describe, it, expect } from 'vitest';
23+
import { assertEngineUpdateDispatch, assertEngineDeleteDispatch } from '@objectstack/metadata-core';
24+
import { STORED_METADATA_BODY_OBJECTS } from '@objectstack/spec/kernel';
25+
import { refuseStoredMetadataBodyWrites, serveStoredMetadataReadsThrough } from './stored-metadata-reader-seam.js';
26+
import { actionBodyRunnerFactory, hookBodyRunnerFactory } from './sandbox/body-runner.js';
27+
import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
28+
29+
const FAMILY = [...STORED_METADATA_BODY_OBJECTS];
30+
const ORDINARY = 'boundary_note';
31+
const WRITE_VERBS = ['insert', 'create', 'update', 'updateById', 'upsert', 'delete', 'deleteById', 'updateMany', 'deleteMany'];
32+
const READ_VERBS = ['find', 'findOne', 'count', 'aggregate'];
33+
34+
/** A scoped-API double that counts `<object>.<verb>` calls and answers neutral values. */
35+
function countingApi(calls: string[] = []): any {
36+
const repo = (name: string) => {
37+
const record = (verb: string) => calls.push(`${name}.${verb}`);
38+
return {
39+
async find() { record('find'); return []; },
40+
async findOne() { record('findOne'); return null; },
41+
async count() { record('count'); return 0; },
42+
async aggregate() { record('aggregate'); return []; },
43+
async insert() { record('insert'); return { id: 'n1' }; },
44+
async create() { record('create'); return { id: 'n1' }; },
45+
async update(data?: any, opts?: any) { assertEngineUpdateDispatch(data, opts); record('update'); return 1; },
46+
async updateById() { record('updateById'); return { id: 'n1' }; },
47+
async upsert() { record('upsert'); return { id: 'n1' }; },
48+
async delete(opts?: any) { assertEngineDeleteDispatch(opts); record('delete'); return 1; },
49+
async deleteById() { record('deleteById'); return true; },
50+
async updateMany() { record('updateMany'); return 0; },
51+
async deleteMany() { record('deleteMany'); return 0; },
52+
};
53+
};
54+
return {
55+
object: repo,
56+
sudo: () => countingApi(calls),
57+
withRunAs: () => countingApi(calls),
58+
async transaction(callback: (trx: any) => Promise<unknown>) { return callback(countingApi(calls)); },
59+
async beginTransaction() { return { ctx: countingApi(calls), handle: 'trx_1', owned: true }; },
60+
};
61+
}
62+
63+
/** The arguments a verb is called with here: a payload, then a by-id or predicate option. */
64+
const argsOf = (verb: string): unknown[] =>
65+
verb === 'update' ? [{ label: 'x' }, { where: { id: 'r1' } }]
66+
: verb === 'delete' ? [{ where: { id: 'r1' } }]
67+
: verb.endsWith('ById') ? ['r1', { label: 'x' }]
68+
: [{ label: 'x' }];
69+
70+
function expectRefused(promise: Promise<unknown>, object: string, verb: string) {
71+
return expect(promise).rejects.toMatchObject({
72+
code: 'PERMISSION_DENIED',
73+
status: 403,
74+
object,
75+
operation: verb,
76+
message: expect.stringContaining('/api/v1/meta/'),
77+
});
78+
}
79+
80+
describe('[#21520] refuseStoredMetadataBodyWrites — every family write is refused before it runs', () => {
81+
for (const object of FAMILY) {
82+
it(`each write verb on '${object}' answers PERMISSION_DENIED / 403 and never reaches the store`, async () => {
83+
const calls: string[] = [];
84+
const api = refuseStoredMetadataBodyWrites(countingApi(calls));
85+
for (const verb of WRITE_VERBS) {
86+
await expectRefused(api.object(object)[verb](...argsOf(verb)), object, verb);
87+
}
88+
expect(calls).toEqual([]);
89+
});
90+
}
91+
92+
it('a predicate write is refused identically whatever its predicate names, and runs nothing', async () => {
93+
const calls: string[] = [];
94+
const api = refuseStoredMetadataBodyWrites(countingApi(calls));
95+
const answers: unknown[] = [];
96+
for (const where of [{ type: 'view' }, { metadata: 'x' }, { checksum: 'x' }]) {
97+
for (const verb of ['updateMany', 'deleteMany', 'update', 'delete']) {
98+
const args = verb === 'update' ? [{ label: 'x' }, { where, multi: true }] : [{ where, multi: true }];
99+
const err: any = await api.object(FAMILY[0])[verb](...args).catch((e: unknown) => e);
100+
answers.push(`${verb}:${err?.code}:${err?.status}:${err?.message}`);
101+
}
102+
}
103+
// Three predicates, four verbs: the answer depends on the verb only.
104+
expect(new Set(answers).size).toBe(4);
105+
expect(calls).toEqual([]);
106+
});
107+
108+
it('reads on a family table pass through to the read seam beneath it', async () => {
109+
const calls: string[] = [];
110+
const api = refuseStoredMetadataBodyWrites(countingApi(calls));
111+
for (const verb of READ_VERBS) await api.object(FAMILY[0])[verb]({});
112+
expect(calls).toEqual(READ_VERBS.map((verb) => `${FAMILY[0]}.${verb}`));
113+
});
114+
115+
it('an ordinary table writes as before (control)', async () => {
116+
const calls: string[] = [];
117+
const api = refuseStoredMetadataBodyWrites(countingApi(calls));
118+
for (const verb of WRITE_VERBS) await api.object(ORDINARY)[verb](...argsOf(verb));
119+
expect(calls).toEqual(WRITE_VERBS.map((verb) => `${ORDINARY}.${verb}`));
120+
});
121+
122+
it('every derived context refuses the same way: sudo, withRunAs, transaction(fn), beginTransaction', async () => {
123+
const calls: string[] = [];
124+
const api = refuseStoredMetadataBodyWrites(countingApi(calls));
125+
await expectRefused(api.sudo().object(FAMILY[0]).insert({ label: 'x' }), FAMILY[0], 'insert');
126+
await expectRefused(api.withRunAs('system', {}).object(FAMILY[1]).insert({ label: 'x' }), FAMILY[1], 'insert');
127+
await api.transaction(async (trx: any) => {
128+
await expectRefused(trx.object(FAMILY[0]).updateMany({ where: { type: 'view' } }), FAMILY[0], 'updateMany');
129+
});
130+
const begun = await api.beginTransaction();
131+
expect(begun.handle).toBe('trx_1');
132+
await expectRefused(begun.ctx.object(FAMILY[0]).delete({ where: { id: 'r1' } }), FAMILY[0], 'delete');
133+
expect(calls).toEqual([]);
134+
});
135+
136+
it('layers over the read seam once: idempotent, and the read seam still sees its own mark', () => {
137+
const served = serveStoredMetadataReadsThrough(countingApi(), {});
138+
const layered = refuseStoredMetadataBodyWrites(served);
139+
expect(refuseStoredMetadataBodyWrites(layered)).toBe(layered);
140+
expect(serveStoredMetadataReadsThrough(layered, {})).toBe(layered);
141+
});
142+
143+
it('the read seam ALONE — a host code handler\'s ctx.api — keeps its family writes (bodies only)', async () => {
144+
const calls: string[] = [];
145+
const api = serveStoredMetadataReadsThrough(countingApi(calls), {});
146+
await api.object(FAMILY[0]).insert({ label: 'x' });
147+
expect(calls).toEqual([`${FAMILY[0]}.insert`]);
148+
});
149+
});
150+
151+
describe('[#21520] through the real sandbox — both body faces hold the refusing API', () => {
152+
const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 });
153+
154+
it('an action body\'s family write is refused with the envelope; its ordinary write lands', async () => {
155+
const calls: string[] = [];
156+
const factory = actionBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' });
157+
const handler = factory({
158+
name: 'writes_family',
159+
type: 'script',
160+
body: {
161+
language: 'js',
162+
capabilities: ['api.write'],
163+
source: `await ctx.api.object('${ORDINARY}').insert({ label: 'x' });
164+
await ctx.api.object('${FAMILY[0]}').insert({ label: 'x' });
165+
return { unreachable: true };`,
166+
},
167+
});
168+
await expect(handler!({ api: countingApi(calls), params: {} }))
169+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
170+
expect(calls).toEqual([`${ORDINARY}.insert`]);
171+
});
172+
173+
it('a hook body on an ordinary table, writing a family table, is refused the same way', async () => {
174+
const calls: string[] = [];
175+
const factory = hookBodyRunnerFactory(runner, { ql: {}, appId: 'boundary' });
176+
const handler = factory({
177+
name: 'ordinary_hook_writes_family',
178+
object: ORDINARY,
179+
events: ['afterInsert'],
180+
body: {
181+
language: 'js',
182+
capabilities: ['api.write'],
183+
source: `await ctx.api.object('${FAMILY[1]}').insert({ label: 'x' });`,
184+
},
185+
} as any);
186+
await expect(handler!({ object: ORDINARY, event: 'afterInsert', input: {}, api: countingApi(calls) }))
187+
.rejects.toMatchObject({ code: 'PERMISSION_DENIED', status: 403 });
188+
expect(calls).toEqual([]);
189+
});
190+
});

‎packages/runtime/src/stored-metadata-reader-seam.ts‎

Lines changed: 91 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,15 @@
5454
* form of one row.
5555
* 3. **Serve what a WRITE verb RETURNS** ({@link serveStoredMetadataWriteReturn}):
5656
* a write whose return carries the family's body or hash is served the same
57-
* projected / keyed way a read is, since a returned row is a serve. Whether a
58-
* body may write the family AT ALL is the write boundary (#21520), not this
59-
* seam: the write itself passes through, only its RETURN is served.
57+
* projected / keyed way a read is, since a returned row is a serve. That
58+
* serve is for the contexts that may still write here (a host code
59+
* handler's `ctx.api`).
60+
* 4. **Refuse a BODY's write** ({@link refuseStoredMetadataBodyWrites}, #21520):
61+
* a sandboxed hook or action body may not write the family's tables at all —
62+
* the metadata protocol is their only writer for an app-authored body — so
63+
* the API a body holds refuses every family-table write before it runs. A
64+
* separate layer, applied only where a body gets its API, because the served
65+
* repository is also a host handler's, and the boundary refuses bodies only.
6066
*
6167
* ## What it does not do
6268
*
@@ -68,6 +74,7 @@
6874
*/
6975

7076
import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel';
77+
import { storedMetadataBodyWriteRefusal } from './stored-metadata-body-boundary.js';
7178
import { isFilterAST, parseFilterAST, resolveSearchFieldResolution } from '@objectstack/spec/data';
7279
import { collectConditionFields } from '@objectstack/plugin-security';
7380
import {
@@ -248,8 +255,8 @@ export async function serveStoredMetadataRead<A>(
248255
* served — a returned row carrying the family's body or hash is a serve too.
249256
* A write whose return is a number (an affected-row count), `null`, or carries
250257
* no family column passes through by reference. ⛔ This serves the RETURN only;
251-
* it neither permits nor refuses the write, which is the write boundary's
252-
* question (#21520).
258+
* it neither permits nor refuses the write: a body's write never gets here
259+
* (the body layer, {@link refuseStoredMetadataBodyWrites}, refuses it first).
253260
*/
254261
async function serveStoredMetadataWriteReturn<A>(object: string, answer: A, engine: unknown): Promise<A> {
255262
if (!isStoredMetadataBodyObject(object)) return answer;
@@ -296,13 +303,14 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un
296303
};
297304
}
298305
if (WRITE_RETURN_VERBS.has(prop)) {
299-
// [#21454] Serve what the write RETURNS. Measured on `main` (pre-#21520):
300-
// an elevated body's family-table write is NOT refused — it runs and
301-
// returns the stored row — so this serve carries real family content.
302-
// [#21520, option A] The write-verb REFUSAL (an elevated body may not
303-
// write a family table at all) attaches HERE, on these same verbs, as a
304-
// throw BEFORE `value.apply` — it needs no reshaping of this branch. This
305-
// seam serves the return and leaves that policy to #21520.
306+
// [#21454] Serve what the write RETURNS — for the contexts that may
307+
// still write here: a host code handler's `ctx.api` (deployer code, the
308+
// same trust as platform code). A sandboxed BODY never reaches this
309+
// branch for a family table: its API carries the body layer
310+
// ({@link refuseStoredMetadataBodyWrites}, #21520), which refuses the
311+
// write before it gets here. The refusal is not attached HERE because
312+
// this repository is also a host handler's, and the boundary refuses
313+
// bodies only.
306314
return async (...args: unknown[]) =>
307315
serveStoredMetadataWriteReturn(objectName, await value.apply(target, args), engine);
308316
}
@@ -328,17 +336,84 @@ function serveRepository(objectName: string, repo: unknown, engine: unknown): un
328336
* A value that is not an object is returned as is.
329337
*/
330338
export function serveStoredMetadataReadsThrough<T>(api: T, engine: unknown): T {
339+
return deriveThroughSeam(api, SERVED_THROUGH_SEAM, (name, repo) => serveRepository(name, repo, engine));
340+
}
341+
342+
/** Marks a scoped context whose family-table writes are already refused for a body. */
343+
const BODY_WRITES_REFUSED = Symbol.for('objectstack.runtime.storedMetadataBodyWritesRefused');
344+
345+
/** The repository verbs a body may still call on a family table: the reads this seam serves. */
346+
const BODY_FAMILY_READS: ReadonlySet<PropertyKey> = new Set([...ROW_SERVING_READS, COUNT_READ]);
347+
348+
/**
349+
* A family table's repository as a sandboxed BODY holds it: the served reads
350+
* pass through, and every other verb — each write alias, and anything not
351+
* known to be a read — is refused before it runs, with the boundary's
352+
* `PERMISSION_DENIED` / 403 and the metadata-API prescription
353+
* ({@link storedMetadataBodyWriteRefusal}). Fail-closed by construction: a verb
354+
* added to the repository later is refused here until it is named a read.
355+
* Refused before the underlying verb is called, so a refused write changes
356+
* nothing and answers the same whatever its payload or predicate names. Any
357+
* other object's repository is returned untouched.
358+
*/
359+
function refuseBodyRepositoryWrites(objectName: string, repo: unknown): unknown {
360+
if (!isStoredMetadataBodyObject(objectName) || repo === null || typeof repo !== 'object') return repo;
361+
return new Proxy(repo as Record<PropertyKey, unknown>, {
362+
get(target, prop) {
363+
const value = Reflect.get(target, prop, target);
364+
if (typeof value !== 'function') return value;
365+
if (BODY_FAMILY_READS.has(prop)) return value.bind(target);
366+
return async () => {
367+
throw storedMetadataBodyWriteRefusal(objectName, String(prop));
368+
};
369+
},
370+
});
371+
}
372+
373+
/**
374+
* [#21520, ruling A] The scoped API a sandboxed BODY (a hook body or an action
375+
* body) holds, with every write of a stored-metadata family table refused: for
376+
* an app-authored body, the metadata protocol is the family's only writer.
377+
* Reads are untouched here — they are served by
378+
* {@link serveStoredMetadataReadsThrough}, which this layers over — and every
379+
* other object writes as before.
380+
*
381+
* Applied at ONE place, the sandbox's `buildSandboxApi`, which only the two body
382+
* runners reach. It is a separate layer rather than a branch of the served
383+
* repository because that repository is also a host code handler's `ctx.api`,
384+
* and the boundary refuses bodies only: the platform's own writers and the
385+
* deployer's host code reach the store through their own imports. Every
386+
* context the API derives is refused the same way (the same walk as the read
387+
* seam). Idempotent, and transparent to the read seam's own marker, so a body
388+
* API served at the action door is still served exactly once.
389+
*/
390+
export function refuseStoredMetadataBodyWrites<T>(api: T): T {
391+
return deriveThroughSeam(api, BODY_WRITES_REFUSED, refuseBodyRepositoryWrites);
392+
}
393+
394+
/**
395+
* The walk both layers share: `api`, and every context it can derive —
396+
* `object(name)`, `sudo()`, `withRunAs(...)`, the context a `transaction(fn)`
397+
* callback receives, and the `ctx` `beginTransaction()` returns — with each
398+
* repository passed through `wrapRepository`. One definition of what a scoped
399+
* API can derive, so neither layer can leave a route around it.
400+
*/
401+
function deriveThroughSeam<T>(
402+
api: T,
403+
marker: symbol,
404+
wrapRepository: (name: string, repo: unknown) => unknown,
405+
): T {
331406
if (api === null || typeof api !== 'object') return api;
332-
if ((api as Record<PropertyKey, unknown>)[SERVED_THROUGH_SEAM] === true) return api;
333-
const wrap = (derived: unknown) => serveStoredMetadataReadsThrough(derived, engine);
407+
if ((api as Record<PropertyKey, unknown>)[marker] === true) return api;
408+
const wrap = (derived: unknown) => deriveThroughSeam(derived, marker, wrapRepository);
334409
return new Proxy(api as unknown as Record<PropertyKey, unknown>, {
335410
get(target, prop) {
336-
if (prop === SERVED_THROUGH_SEAM) return true;
411+
if (prop === marker) return true;
337412
const value = Reflect.get(target, prop, target);
338413
if (typeof value !== 'function') return value;
339414
switch (prop) {
340415
case 'object':
341-
return (name: string, ...rest: unknown[]) => serveRepository(name, value.call(target, name, ...rest), engine);
416+
return (name: string, ...rest: unknown[]) => wrapRepository(name, value.call(target, name, ...rest));
342417
case 'sudo':
343418
case 'withRunAs':
344419
return (...args: unknown[]) => wrap(value.apply(target, args));

0 commit comments

Comments
 (0)