Skip to content

Commit 3556b67

Browse files
os-zhuangclaude
andauthored
fix(security): guard the write-response internal-field property, not one class (#8497) (#8587)
* fix(security): guard the write-response internal-field property, not one class (#8497) Widen the #7823 write-response `internal: true` guard from "every `*Data` face on the protocol class" to the property it exists to hold: no response body an external caller receives from a write carries an `internal: true` value. Widening it found a live leak. `@objectstack/mcp`'s stdio bridge is engine-only by construction and its `create` arm echoed `engine.insert`'s result — whole since #7823 relocated the strip off the engine — straight back to the MCP caller. Measured, fixed, and pinned. - mcp: strip on `create` (the leak) and on `update` (so a caller cannot use their own patch as an oracle on an internal column). - core: the shared strip helper moves here from metadata-protocol, which re-exports it unchanged. `rest` and `mcp` both write through the engine directly and neither depends on metadata-protocol, so the old home forced every new mouth to duck-type or restate the rule. - rest + mcp: two new tripwires, same mechanism as the shipped one (runtime enumeration, total disposition map, negative control). The shipped protocol tripwire is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy * test(rest): keep the new tripwire out of the TEST_DEBT ratchet (#8497) `packages/rest` hides its test files from its own `typecheck` script, so the package typecheck passed while `check:type-check-debt` measured +2 raw tsc errors against the frozen TEST_DEBT entry — both of them in the new tripwire: a relative import missing its `.js` extension under NodeNext, and `registry.registerObject` called without its required `packageId`. Fixed rather than ledgered: TEST_DEBT is a shrink-only ratchet and raising it is maintainer-only. Re-measured at 155, exactly the recorded value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RDTnVvsgA6cUZ4xFVtPZRy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c433a8 commit 3556b67

8 files changed

Lines changed: 988 additions & 109 deletions
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/metadata-protocol": patch
4+
"@objectstack/mcp": patch
5+
---
6+
7+
fix(security): the MCP stdio bridge stops echoing `internal: true` columns from a write, and the write-response guarantee is guarded as a PROPERTY rather than per-class (#8497)
8+
9+
**A live leak, found by widening a guard.** #7823 relocated the `internal: true`
10+
write-response strip to the generic-data-path ingress and gated the relocation on
11+
a tripwire that enumerates every `*Data` face on the protocol class. The card that
12+
produced this change observed that the guard's coverage — *"every `*Data` face on
13+
one class"* — is narrower than the property that needs holding — *"no response body
14+
an external caller receives from a write carries an `internal: true` value"* — and
15+
that `@objectstack/rest`'s cross-object batch (a direct `ql.update`) was the
16+
standing proof the two are not the same set.
17+
18+
Widening the guard to the property immediately found a second direct mouth that
19+
was **not** covered, and it was leaking. `@objectstack/mcp`'s stdio bridge
20+
(`stdio-data-bridge.ts`) is engine-only by construction — the long-lived stdio
21+
host cannot reuse the runtime's request-shaped `callData` builder — and its
22+
`create` arm handed `engine.insert`'s result straight back to the MCP caller.
23+
Since #7823 the engine deliberately keeps its write results whole, so the flagged
24+
column rode the tool response verbatim. Measured before the fix:
25+
26+
```
27+
{"object":"vault","id":"r1","record":{"name":"row","id":"r1","vault_secret":"<the stored secret>"}}
28+
```
29+
30+
The file's own header had listed its protocol-layer divergences as *"deliberate,
31+
filed, not security"*. One limb of that list **was** security, and the header now
32+
says so.
33+
34+
**What changed**
35+
36+
- `@objectstack/mcp` — the stdio bridge's `create` runs its response record
37+
through the shared strip. `update` does too: that arm discards the engine's
38+
write result and echoes the read-path row plus the caller's own patch, so no
39+
*stored* value could reach it, but a caller who puts an `internal: true` key in
40+
`data` would otherwise get it echoed back — their own bytes used as an oracle
41+
for a column the flag says is never returned. Read verbs are untouched (the
42+
engine's read-path strip is unchanged).
43+
- `@objectstack/core` — the strip helper
44+
(`omitInternalFieldsFromWriteResponse` / `collectInternalWriteResponseFields`)
45+
moved here from `@objectstack/metadata-protocol`. It shipped beside the protocol
46+
class when that class was its only caller, but the generic write mouths are not
47+
all on it: `rest` and `mcp` both reach the engine directly and **neither depends
48+
on `@objectstack/metadata-protocol`**, so the old home forced each new mouth to
49+
choose between a duck-typed reach through a protocol instance and a private
50+
restatement of a security-relevant rule. `core` is the floor all three already
51+
depend on, and already hosts this class of shared write-path helper
52+
(`bulk-write.ts`). No behaviour change and no API change:
53+
`@objectstack/metadata-protocol` re-exports both names unchanged.
54+
55+
**What guards it now.** Two new tripwires join the shipped one — which is **not**
56+
replaced: its runtime prototype walk and its `leakyData` negative control are
57+
untouched. Each is a runtime enumeration no author can dodge by adding code
58+
without touching it, and each fails on a surface it has no disposition for:
59+
60+
- `metadata-protocol` — walks the protocol class for `*Data` faces (unchanged);
61+
- `rest` — walks `RestServer.getRoutes()` for HTTP write routes, drives the ten
62+
data-plane ones (including `POST /batch`, the direct-`ql.update` mouth) against
63+
a fixture whose stored rows carry a flagged sentinel, and deep-scans each
64+
response body;
65+
- `mcp` — walks the `McpDataBridge` faces the factory actually returns.
66+
67+
Every driven case also asserts a control value is present, so a refusal or an
68+
empty body cannot satisfy "no sentinel" by returning nothing.
69+
70+
Reverse-verified in both directions, the discipline #7823's own fix used: deleting
71+
the strip from the REST batch arm turned the REST tripwire red on exactly that
72+
route; adding a *second* unstripped direct engine mouth turned it red again;
73+
removing the new MCP strip turned the MCP tripwire red; every restore was proven
74+
byte-identical with `git hash-object`.

packages/core/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ export * from './utils/datetime.js';
3636
// Export the shared batched-write helper (framework#2678)
3737
export * from './utils/bulk-write.js';
3838

39+
// Export the shared write-response `internal: true` strip (#7823, #8497) — the
40+
// ONE helper every write mouth that answers an external caller runs its
41+
// records through. It lives here, on the floor all three transports share,
42+
// because the mouths are not all on the protocol class: `rest` and `mcp` both
43+
// reach the engine directly and neither depends on `@objectstack/metadata-
44+
// protocol` (which re-exports these two names unchanged).
45+
export * from './utils/internal-write-response.js';
46+
3947
// Export the migration-journal runner (ADR-0119 D2, #4617) — chunk-atomic
4048
// migrations with durable recovery, plus the shared `engineCanRollBack` gate
4149
// that `@objectstack/metadata-protocol`'s atomic `batchData` also uses.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#7823 / #8497] The write-response half of the `internal: true` guarantee —
5+
* THE single helper every write mouth that returns a body to an external
6+
* caller passes its record(s) through.
7+
*
8+
* ## The contract
9+
*
10+
* A field declared `internal: true` is *never returned on the generic data
11+
* path* (#7728). The READ half lives in the engine (`omitInternalFields` runs
12+
* on every find/findOne result). The WRITE-RESPONSE half lives HERE.
13+
*
14+
* ## Why the ingress and not the engine (the measured history, #7823)
15+
*
16+
* The first shape stripped `internal` fields inside the engine's insert and
17+
* by-id-update paths. That conflated two different guarantees:
18+
*
19+
* - "never returned on the generic data path" — the flag's sentence, about
20+
* what an EXTERNAL caller receives; and
21+
* - "never returned to the engine-level caller that performed the write" —
22+
* which no ruling ever asked for, and which is FALSE for credential mint:
23+
* better-auth's `createWithHooks` reads the minted `sys_session` row back
24+
* off the insert result, so the engine-side strip broke `signIn`/`signUp`
25+
* outright (measured: `verify signIn: no token in response`).
26+
*
27+
* Plain removal of the engine limbs was ALSO measured wrong: the by-id-update
28+
* strip was the sole closure of #7728's fourth surface — with it neutralised,
29+
* `PATCH /data/sys_api_key/{id}` answered 200 with the stored 64-hex `key`
30+
* hash in the body. Both measurements are satisfiable at exactly one boundary:
31+
* the mouth that builds the external 201/200 body. Engine write results keep
32+
* the stored row whole (mint works); every external write response is stripped
33+
* through this helper (the hash never leaves); the read path is untouched.
34+
*
35+
* ## Why this module sits in `@objectstack/core` (#8497)
36+
*
37+
* It shipped inside `@objectstack/metadata-protocol`, next to the protocol
38+
* class that was then its only caller. That placement encoded an assumption
39+
* the surface does not honour: **the generic write mouths are not all on the
40+
* protocol class.** Two transports reach the engine directly —
41+
*
42+
* - `@objectstack/rest` (`rest-server.ts`, the cross-object `POST /batch`
43+
* update arm's direct `ql.update`), and
44+
* - `@objectstack/mcp` (`stdio-data-bridge.ts`, whose `create` handed the
45+
* engine's insert result straight back to the MCP client — a MEASURED leak
46+
* of the flagged column, found by widening this guard's scope in #8497),
47+
*
48+
* — and neither package depends on `@objectstack/metadata-protocol`. The old
49+
* home therefore forced each new mouth to choose between a duck-typed reach
50+
* through a protocol instance (what `rest` does) and a private restatement of
51+
* the rule (a third copy of a security-relevant predicate). `@objectstack/core`
52+
* is the floor all three already depend on, and it already hosts exactly this
53+
* class of shared write-path helper (`bulk-write.ts`, used by both
54+
* `metadata-protocol` and `rest` so neither reimplements batching). One helper,
55+
* reachable from every mouth, is the whole point of the flag being structural.
56+
*
57+
* `@objectstack/metadata-protocol` re-exports both functions unchanged, so its
58+
* public API is byte-identical across the move.
59+
*
60+
* ## The residual risk, and what gates it
61+
*
62+
* Response-body policy at the mouth means a FUTURE write mouth that forgets
63+
* this helper leaks silently. Three tripwires hold the property, each an
64+
* enumeration no author can dodge by adding code without touching it:
65+
*
66+
* - `protocol.write-response-internal-fields.tripwire.test.ts`
67+
* (`metadata-protocol`) walks the protocol class's prototype for `*Data`
68+
* faces;
69+
* - `rest-write-response-internal-fields.tripwire.test.ts` (`rest`) walks
70+
* `RestServer.getRoutes()` for HTTP write routes;
71+
* - `mcp-write-response-internal-fields.tripwire.test.ts` (`mcp`) walks the
72+
* `McpDataBridge` write faces.
73+
*
74+
* Together they assert the PROPERTY — "no response body an external caller
75+
* receives from a write carries an `internal: true` value" — rather than the
76+
* shape of any one class. Adding a write mouth? Route its response records
77+
* through this helper and register it with the tripwire that enumerates its
78+
* surface.
79+
*
80+
* ## Semantics
81+
*
82+
* Mirrors the engine's `collectInternalReadFields` rule exactly — a field
83+
* participates iff its declaration carries `internal === true` (strict
84+
* boolean; truthy strings and numbers do not count, same as the engine).
85+
* `@objectstack/core` cannot import that collector (`@objectstack/objectql`
86+
* sits above this package), so the rule is restated here in full;
87+
* `internal-fields.test.ts` in objectql and the tripwires above pin the same
88+
* spelling from both sides. OMIT, not mask, for the #7728 reasons: the flag's
89+
* columns are `required`, so a mask carries zero bits while still shipping a
90+
* value under a field whose description promises none.
91+
*
92+
* Deletion is IN PLACE and idempotent: records that already lack the field
93+
* (a re-stripped read result, a fake engine that never returned it) pass
94+
* through unchanged, and non-record values (`null`, an affected-row count, a
95+
* driver's boolean delete verdict) are skipped rather than judged.
96+
*/
97+
98+
/** Minimal view of an object schema this module reads — the field map only. */
99+
interface SchemaWithFields {
100+
fields?: Record<string, { internal?: unknown } | undefined> | undefined;
101+
}
102+
103+
/**
104+
* Collect the names of fields declared `internal: true` on `schema`.
105+
*
106+
* Same verdicts as objectql's `collectInternalReadFields` (see the module
107+
* header for why it is restated rather than imported): strict `=== true`,
108+
* empty result for a missing/field-less schema.
109+
*/
110+
export function collectInternalWriteResponseFields(schema: unknown): string[] {
111+
const fields = (schema as SchemaWithFields | null | undefined)?.fields;
112+
if (!fields || typeof fields !== 'object') return [];
113+
const out: string[] = [];
114+
for (const [name, def] of Object.entries(fields)) {
115+
if (def && def.internal === true) out.push(name);
116+
}
117+
return out;
118+
}
119+
120+
/**
121+
* Drop every `internal: true` field from a write response's record(s), in
122+
* place. THE single helper every external write mouth goes through — see the
123+
* module header; the three tripwires enforce the "every".
124+
*
125+
* @param schema The registered object schema (`engine.registry.getObject(...)`
126+
* / the protocol's own registry view / `metadataService
127+
* .getObject(...)`). An unknown object (no schema) strips
128+
* nothing — the write itself would have been refused upstream
129+
* by the object-existence gate.
130+
* @param records A single record, an array of records, or anything a write
131+
* mouth hands back where a record could sit (`null`, a count, a
132+
* boolean): non-objects are skipped, arrays are walked.
133+
*/
134+
export function omitInternalFieldsFromWriteResponse(schema: unknown, records: unknown): void {
135+
if (!records) return;
136+
const internalFields = collectInternalWriteResponseFields(schema);
137+
if (internalFields.length === 0) return;
138+
const list = Array.isArray(records) ? records : [records];
139+
for (const row of list) {
140+
if (!row || typeof row !== 'object') continue;
141+
for (const field of internalFields) delete (row as Record<string, unknown>)[field];
142+
}
143+
}

0 commit comments

Comments
 (0)