Skip to content

Commit a4a9944

Browse files
authored
fix(metadata-protocol): findData must not take its execution context from the request (#3960) (#3961)
From the #3946 sweep's leftover question — whether expand's advanced usage (a caller-supplied Record<string, QueryAST> whose sub-ASTs carry their own `object`) is a cross-object read channel. It is NOT: expandRelatedRecords takes its target from the parent schema, re-enters engine.find so the referenced object's RLS+FLS both run, $and-merges a nested `where` instead of spreading it over the id filter, and caps depth. One layer down is the problem. findData built `{ ...request.query }` then assigned `context` from `request.context` CONDITIONALLY. `request.query` is the caller's raw bag (the REST POST /data/:object/query route passes req.body straight in) and `context` is in the known-params set, so it survived and became the execution context whenever no server context resolved. plugin-security opens with `if (opCtx.context?.isSystem) return next()` — the whole RLS/FLS/CRUD chain skipped — and `__expandRead` collects the #2850 waiver; neither is schema-stripped on the read path. enforceAuth kept it unreachable, so this was a fail-open default rather than a live exploit. Any inbound `context` is now dropped unconditionally: the protocol owns this invariant and must not delegate it upward.
1 parent e2c64f1 commit a4a9944

3 files changed

Lines changed: 162 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): findData must not take its execution context from the request (#3960)
6+
7+
Came out of the #3946 sweep's leftover question — whether `expand`'s "advanced
8+
usage" (a caller-supplied `Record<string, QueryAST>` whose sub-ASTs each carry an
9+
`object`) is a cross-object read channel. **It is not**, and that needs saying
10+
because the answer is load-bearing: `expandRelatedRecords` takes its target from
11+
the parent schema (the expand KEY must be a real `reference` field; the sub-AST's
12+
`object` is never read), re-enters `engine.find` so the referenced object's RLS +
13+
FLS both run, `$and`-merges a nested `where` instead of spreading it over the id
14+
filter, and caps depth. No change needed there.
15+
16+
What the investigation did turn up is one layer down. `findData` built its engine
17+
options as `{ ...request.query }` and then assigned `context` from
18+
`request.context` **conditionally**:
19+
20+
- `request.query` is the caller's raw bag on every ingress — the REST
21+
`POST /data/:object/query` route passes `req.body` straight in as `query`;
22+
- `context` sits in the known-params set, so it was not swept into the
23+
implicit-filter bucket either — it survived the spread untouched;
24+
- so when no server context resolved, the caller's `context` *became* the
25+
operation's execution context.
26+
27+
Everything hangs off that value. plugin-security's middleware opens with
28+
`if (opCtx.context?.isSystem) return next()` — the entire RLS / FLS / CRUD chain
29+
skipped — and `__expandRead: true` collects the #2850 waiver on the object-level
30+
CRUD gate. Neither is ever schema-stripped on the read path:
31+
`ExecutionContextSchema.parse` runs only in `engine.createContext`, which reads
32+
do not use.
33+
34+
Route-level `enforceAuth` is what kept this unreachable: anonymous data requests
35+
are refused unless a deployment sets `requireAuth: false`. That makes it a
36+
fail-OPEN default rather than a live exploit — and not something the protocol
37+
should delegate upward. `findData` now drops any inbound `context`
38+
unconditionally before the assignment, so the execution context can only come
39+
from `request.context`.
40+
41+
Verified end-to-end at the protocol layer (a forged
42+
`{ isSystem, userId, __expandRead }` reached `engine.find` verbatim before, is
43+
dropped after). The anonymous HTTP reachability half is NOT verified — see #3960
44+
for exactly what was and was not reproduced. No caller regresses: the only
45+
in-repo builder of these args (`rest/src/import-runner.ts` `findArgsBase`) passes
46+
`context` at the top level, never inside `query`.

packages/metadata-protocol/src/protocol.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2659,6 +2659,29 @@ export class ObjectStackProtocolImplementation implements
26592659
// probed for query-shape validity (nor reach the driver as a table).
26602660
this.assertObjectRegistered(request.object);
26612661
const options: any = { ...request.query };
2662+
// The execution context is SERVER-DERIVED and never caller input.
2663+
//
2664+
// `request.query` is the raw request bag on every ingress that reaches
2665+
// here — the REST `POST /data/:object/query` route hands `req.body`
2666+
// straight in as `query`. `context` is in the known-params set below, so
2667+
// it was not swept into the implicit-filter bucket either: a caller's
2668+
// `context` survived this spread and, because the assignment below is
2669+
// conditional, became the operation's execution context whenever no
2670+
// server context resolved (an anonymous request on a deployment that
2671+
// set `requireAuth: false`).
2672+
//
2673+
// What rides on it is total: plugin-security's middleware opens with
2674+
// `if (opCtx.context?.isSystem) return next()` — the entire RLS / FLS /
2675+
// CRUD chain skipped — and `__expandRead` waives the object-level CRUD
2676+
// gate for public objects (#2850). Neither is ever schema-stripped on
2677+
// this path: `ExecutionContextSchema.parse` runs only in
2678+
// `engine.createContext`, which the read path does not use.
2679+
//
2680+
// Route-level `enforceAuth` is what kept that from being reachable, so
2681+
// this was a fail-OPEN default one layer down. Drop any inbound
2682+
// `context` unconditionally: the protocol must not depend on a gate
2683+
// above it staying switched on.
2684+
delete options.context;
26622685
// Forward the dispatcher's ExecutionContext so RBAC/RLS middleware
26632686
// can apply per-request enforcement. The protocol layer is purely
26642687
// a normalizer — it must never strip security context.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The execution context must never come off the wire.
4+
//
5+
// `findData` built its engine options as `{ ...request.query }` and then
6+
// overwrote `context` from `request.context` ONLY when that was defined. The
7+
// query bag is caller-controlled on every ingress that reaches here (the REST
8+
// `POST /data/:object/query` route hands `req.body` straight in as `query`), and
9+
// `context` is in the schema's known-params set, so it was NOT swept into the
10+
// implicit-filter bucket — it survived into `engine.find` as the operation's
11+
// execution context whenever no server context resolved.
12+
//
13+
// What rides on that context is total: plugin-security's middleware opens with
14+
// `if (opCtx.context?.isSystem) return next()` — the whole RLS/FLS/CRUD chain
15+
// skipped — and `__expandRead` waives the object-level CRUD gate for public
16+
// objects. Neither is ever schema-stripped on the read path
17+
// (`ExecutionContextSchema.parse` runs only in `createContext`).
18+
//
19+
// The auth gate is what stands between that and a live exploit: `enforceAuth`
20+
// refuses anonymous data requests unless a deployment sets `requireAuth: false`.
21+
// That makes this a fail-OPEN default one layer down — the protocol should not
22+
// depend on a route-level gate staying on. These tests pin the invariant at the
23+
// layer that owns it.
24+
25+
import { describe, it, expect, vi } from 'vitest';
26+
import { ObjectStackProtocolImplementation } from './protocol.js';
27+
28+
const SCHEMA = { name: 'invoice', fields: { title: { name: 'title', type: 'text' } } };
29+
30+
function makeProtocol() {
31+
const find = vi.fn(async () => []);
32+
const count = vi.fn(async () => 0);
33+
const engine = {
34+
registry: { getObject: (n: string) => (n === 'invoice' ? SCHEMA : undefined) },
35+
find,
36+
count,
37+
};
38+
return { p: new ObjectStackProtocolImplementation(engine as any), find };
39+
}
40+
41+
/** The engine options `findData` handed to `engine.find`. */
42+
const optionsFrom = (find: any) => find.mock.calls[0][1];
43+
44+
describe('findData never takes its execution context from the caller', () => {
45+
it('drops a body-supplied context when no server context resolved (the anonymous shape)', async () => {
46+
const { p, find } = makeProtocol();
47+
await p.findData({
48+
object: 'invoice',
49+
query: { context: { isSystem: true, userId: 'root', __expandRead: true } },
50+
} as any);
51+
52+
expect(find).toHaveBeenCalledTimes(1);
53+
expect(optionsFrom(find).context).toBeUndefined();
54+
});
55+
56+
it('the resolved context wins and is not merged with the caller\'s', async () => {
57+
const { p, find } = makeProtocol();
58+
const resolved = { userId: 'u1', positions: [] };
59+
await p.findData({
60+
object: 'invoice',
61+
query: { context: { isSystem: true } },
62+
context: resolved,
63+
} as any);
64+
65+
expect(optionsFrom(find).context).toBe(resolved);
66+
expect((optionsFrom(find).context as any).isSystem).toBeUndefined();
67+
});
68+
69+
it('a caller `context` does not become an implicit field filter either', async () => {
70+
// It must be dropped, not folded into `where` as `where.context` — that
71+
// would turn a forged principal into a query against a column that does
72+
// not exist (a confusing 400 instead of a clean ignore).
73+
const { p, find } = makeProtocol();
74+
await p.findData({ object: 'invoice', query: { context: { isSystem: true } } } as any);
75+
76+
const opts = optionsFrom(find);
77+
expect(opts.where).toBeUndefined();
78+
expect(opts.context).toBeUndefined();
79+
});
80+
81+
it('still forwards a real query alongside a forged context', async () => {
82+
const { p, find } = makeProtocol();
83+
await p.findData({
84+
object: 'invoice',
85+
query: { where: { title: 'x' }, limit: 5, context: { isSystem: true } },
86+
} as any);
87+
88+
const opts = optionsFrom(find);
89+
expect(opts.where).toEqual({ title: 'x' });
90+
expect(opts.limit).toBe(5);
91+
expect(opts.context).toBeUndefined();
92+
});
93+
});

0 commit comments

Comments
 (0)