Skip to content

Commit 9765a4d

Browse files
qq9340100claude
andauthored
fix(rest): normalize the :type segment once per handler so the plural spelling cannot skip the §6.7 audience gate (#6241) (#6348)
The single-item metadata read's cached branch excluded `doc` / `book` by comparing the RAW `:type` path segment against singular literals. The route serves both spellings and Prime Directive #3 makes the plural one canonical, so `GET /api/v1/meta/books/:name` did not match the exclusion, took the cached branch, and the ADR-0046 §6.7 audience gate — which lives in the uncached branch — never ran. `enableCache` defaults to true, so the failing path was the default one, and the failure was fail-open: a `{ permissionSet }`-gated book was served in full to a signed-in caller holding no set. This is #3984 recurring in the same file eight days later, so the fix takes the structural form #3984 already ruled rather than correcting two literals: the handler normalizes once at the top (`metaType`) and every gate below reads that local. The cache exclusion and the §6.7 gate now share one predicate (`isAudienceGatedType`), so they cannot drift apart. Also adds `check:meta-type-normalized` — an AST-based guard (comments invisible, so the file's own post-mortems still quote the bad pattern) refusing any raw `:type` comparison, switch discriminant or membership test in packages/rest/src. Zero exemptions. Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW Co-authored-by: Claude <noreply@anthropic.com>
1 parent d8e8d9c commit 9765a4d

6 files changed

Lines changed: 574 additions & 16 deletions

File tree

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
"@objectstack/rest": patch
3+
---
4+
5+
fix(rest): `GET /meta/books/:name` no longer bypasses the ADR-0046 §6.7 audience gate (#6241)
6+
7+
The single-item metadata read has a cached branch and an uncached one, and the
8+
ADR-0046 §6.7 audience gate lives in the uncached one. The comment above the
9+
cached branch's entry condition has always stated why `doc` and `book` must skip
10+
it:
11+
12+
> `doc` and `book` bypass the shared cache: their §6.7 audience gate is
13+
> per-caller, and a shared ETag would leak gated content across viewers.
14+
15+
The condition beneath that sentence compared the **raw** `:type` path segment
16+
against the literals `'doc'` / `'book'`. The route serves both spellings, and
17+
Prime Directive #3 makes the **plural** one canonical — so
18+
`GET /api/v1/meta/books/:name` did not match the exclusion, took the cached
19+
branch, and the audience gate never ran. `enableCache` defaults to `true`, which
20+
made the failing path the default one.
21+
22+
Measured against a real `RestServer` — one book declaring
23+
`audience: { permissionSet: … }`, one signed-in caller holding no permission
24+
set:
25+
26+
```
27+
singular "book" :: cachedCalls=0 status=[403] PERMISSION_DENIED
28+
plural "books" :: cachedCalls=1 status=[] full gated body served
29+
```
30+
31+
Same book, same caller, two spellings of one route. `GET /meta/docs/:name` took
32+
the same path. This was **fail-open**: the wrong outcome is disclosure of gated
33+
documentation, not an availability error.
34+
35+
**The fix is structural, not two corrected literals.** This is #3984 recurring
36+
in the same file eight days later, so the handler now normalizes the type
37+
**once** at the top (`RestServer.metaTypeSingular`) and every gate below reads
38+
that local — a per-type gate added later has no raw param in scope to compare
39+
against by accident. The cache exclusion and the §6.7 gate now read one shared
40+
predicate, so "which types bypass the cache" and "which types are audience
41+
gated" can no longer drift apart. A repository guard
42+
(`pnpm check:meta-type-normalized`, AST-based, zero exemptions) refuses the next
43+
raw comparison in `packages/rest/src`.
44+
45+
**Behaviour change worth knowing:** `GET /meta/docs/:name` and
46+
`GET /meta/books/:name` now take the uncached branch, as their singular
47+
spellings always did, so those two responses no longer carry an `ETag` /
48+
`Cache-Control` validator and a conditional request no longer answers `304`. No
49+
other metadata type is affected. The cost is only the 304's saved bytes —
50+
`getMetaItemCached` delegates to `getMetaItem`, so the server does identical
51+
work either way — and the ETag it gave up was a hash of the **unfiltered**
52+
document, which is the cross-viewer leak the exclusion exists to prevent.

.github/workflows/lint.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,20 @@ jobs:
211211
- name: Wildcard fall-through guard
212212
run: pnpm check:wildcard-fallthrough
213213

214+
# Raw `:type` route-param comparison guard (#6241). The `/meta/:type`
215+
# routes serve BOTH spellings and Prime Directive #3 makes the PLURAL one
216+
# canonical, so a gate comparing the raw param is a gate the canonical
217+
# spelling skips. That is one authorization bypass fixed three times in
218+
# one file: #3984 (every per-type gate), #5881 (the dashboard exclusion),
219+
# #6241 (the doc/book cache exclusion, still literal eight days after
220+
# #3984's structural fix). Each was found by hand; per-defect tests pin
221+
# the gates that exist today and nothing refused the NEXT raw comparison.
222+
# AST-based on purpose — the file documents the bad pattern in prose, and
223+
# a textual scan would flag its own post-mortems. Zero exemptions today.
224+
# Runs its own --self-test first.
225+
- name: Normalized metadata-type guard
226+
run: pnpm check:meta-type-normalized
227+
214228
# Init-service declaration guard (#4471, ADR-0116). The kernel's ordering
215229
# contract (dependencies / optionalDependencies / requiresServices /
216230
# providesServices) was complete but VOLUNTARY: a plugin that resolves

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs",
4949
"check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs",
5050
"check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs",
51+
"check:meta-type-normalized": "node scripts/check-meta-type-normalized.mjs --self-test && node scripts/check-meta-type-normalized.mjs",
5152
"check:init-service-contract": "node scripts/check-init-service-contract.mjs --self-test && node scripts/check-init-service-contract.mjs",
5253
"check:durability-log-level": "node scripts/check-durability-degradation-log-level.mjs --self-test && node scripts/check-durability-degradation-log-level.mjs",
5354
"check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs",

packages/rest/src/meta-audience-plural.test.ts

Lines changed: 162 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ async function getItem(rest: any, type: string, name: string) {
6969
const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name');
7070
if (!route) throw new Error('meta/:type/:name route not registered');
7171
const res = makeRes();
72-
await route.handler({ method: 'GET', params: { type, name }, query: {}, body: {} }, res);
72+
// `headers` is not optional dressing: the cached branch reads
73+
// `req.headers['if-none-match']`, so a request object without it would throw
74+
// its way into a 400 and read as "the gate denied" for the wrong reason.
75+
await route.handler({ method: 'GET', params: { type, name }, query: {}, body: {}, headers: {} }, res);
7376
return res;
7477
}
7578

@@ -129,3 +132,161 @@ describe('the same spelling sensitivity on the other per-type gates', () => {
129132
expect(names(plural.body)).toEqual(names(singular.body));
130133
});
131134
});
135+
136+
// ---------------------------------------------------------------------------
137+
// [#6241] The same gate, one branch further in: the CACHED read path.
138+
//
139+
// Everything above tests a protocol double with no `getMetaItemCached`, so the
140+
// single-item read always fell through to the uncached branch — the branch that
141+
// holds the §6.7 gate. A real deployment does not look like that: `enableCache`
142+
// defaults to `true` and the metadata protocol ships `getMetaItemCached`, so the
143+
// DEFAULT single-item read took the cached branch, whose entry condition
144+
// excluded `doc` / `book` by LITERAL comparison against the raw `:type` segment:
145+
//
146+
// … && req.params.type !== 'doc' && req.params.type !== 'book'
147+
//
148+
// `/meta/books/:name` is the canonical spelling (Prime Directive #3) and the
149+
// route serves it, so the plural read walked past the exclusion, took the cached
150+
// branch, and the audience gate never ran. Measured on the real `RestServer`
151+
// before the fix — one `{ permissionSet }`-gated book, one signed-in caller who
152+
// holds no set:
153+
//
154+
// singular "book" :: cachedCalls=0 status=[403] PERMISSION_DENIED
155+
// plural "books" :: cachedCalls=1 status=[] full gated body served
156+
//
157+
// That is #3984's defect recurring in the same file, and it is why the fix
158+
// normalizes once at the top of the handler instead of adding a third correctly
159+
// normalized comparison beside two wrong ones.
160+
//
161+
// The trade this pins: `docs` / `books` plural reads now leave the cached
162+
// branch, so they carry no ETag. Same trade #5881 made for `dashboard`, on a
163+
// harder reason — the comment above the exclusion has always said a shared ETag
164+
// over a per-caller-gated document leaks it across viewers, and
165+
// `getMetaItemCached` delegates to `getMetaItem`, so only the 304's saved bytes
166+
// are given up.
167+
// ---------------------------------------------------------------------------
168+
describe('#6241 — the cached branch cannot be spelled around either', () => {
169+
/** A doc the gated book claims by rule, plus one no book claims. */
170+
const ADMIN_DOC = { name: 'admin_runbook', label: 'Runbook' };
171+
const OPEN_DOC = { name: 'intro', label: 'Intro' };
172+
const CLAIMING_GATED_BOOK = {
173+
name: 'admin_guide',
174+
label: 'Admin Guide',
175+
audience: { permissionSet: 'crm_admin' },
176+
groups: [{ key: 'admin', label: 'Admin', include: 'admin_*' }],
177+
};
178+
/** A type with no per-caller gate at all — the positive control's subject. */
179+
const VIEW_ITEM = { name: 'account_list', label: 'Accounts' };
180+
181+
/**
182+
* The DEFAULT deployment shape: no `metadata` block at all (so `enableCache`
183+
* is its default `true`) and a protocol offering BOTH reads, so which branch
184+
* the handler picks is the thing under test rather than an artefact of a
185+
* double that only implements one.
186+
*/
187+
function setupCached() {
188+
const protocol: any = {
189+
getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }),
190+
getMetaTypes: vi.fn().mockResolvedValue([]),
191+
getMetaItems: vi.fn(async ({ type }: any) => {
192+
const t = String(type ?? '');
193+
if (t === 'book' || t === 'books') return [PUBLIC_BOOK, CLAIMING_GATED_BOOK];
194+
if (t === 'doc' || t === 'docs') return [ADMIN_DOC, OPEN_DOC];
195+
return [];
196+
}),
197+
getMetaItem: vi.fn(async ({ type, name }: any) => {
198+
const all: any[] = [PUBLIC_BOOK, CLAIMING_GATED_BOOK, ADMIN_DOC, OPEN_DOC, VIEW_ITEM];
199+
const item = all.find((i) => i.name === name);
200+
return item ? { type, name, item } : { type, name };
201+
}),
202+
// Present and eligible — exactly what a default deployment has, and what
203+
// every test above this line was missing. It answers the UNFILTERED
204+
// document with an ETag over it, which is the leak the exclusion exists
205+
// to prevent.
206+
getMetaItemCached: vi.fn(async ({ name }: any) => ({
207+
data: [PUBLIC_BOOK, CLAIMING_GATED_BOOK, ADMIN_DOC, OPEN_DOC, VIEW_ITEM]
208+
.find((i) => i.name === name),
209+
etag: { value: 'etag-unfiltered', weak: false },
210+
cacheControl: { directives: ['private', 'no-cache'] },
211+
notModified: false,
212+
})),
213+
findData: vi.fn().mockResolvedValue([]),
214+
};
215+
const rest: any = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: false } } as any);
216+
// A signed-in caller who holds no permission set — the 403 case, not the
217+
// anonymous 401 one, so the pin cannot pass by accident on the auth gate.
218+
rest.resolveExecCtx = async () => ({ userId: 'u1' });
219+
rest.securityServiceProvider = async () => ({ resolvePermissionSetNames: async () => [] });
220+
rest.registerRoutes();
221+
return { rest, protocol };
222+
}
223+
224+
it('a {permissionSet}-gated book is 403 on the PLURAL spelling, not 200', async () => {
225+
const { rest, protocol } = setupCached();
226+
const res = await getItem(rest, 'books', 'admin_guide');
227+
228+
// Before the fix: 200 with `{ item: { audience: { permissionSet: … } } }`.
229+
expect(res.statusCode).toBe(403);
230+
expect(res.body?.code ?? res.body?.error?.code).toBe('PERMISSION_DENIED');
231+
// …and it got there by NOT taking the cached branch, which is the actual
232+
// mechanism — asserting only the status would leave a fix that gated the
233+
// cached body under an unfiltered ETag looking correct.
234+
expect(protocol.getMetaItemCached).not.toHaveBeenCalled();
235+
});
236+
237+
it('the singular spelling keeps denying it — no regression on the path that worked', async () => {
238+
const { rest, protocol } = setupCached();
239+
const res = await getItem(rest, 'book', 'admin_guide');
240+
241+
expect(res.statusCode).toBe(403);
242+
expect(protocol.getMetaItemCached).not.toHaveBeenCalled();
243+
});
244+
245+
it('a doc claimed only by the gated book is 403 on /meta/docs/:name too', async () => {
246+
// §6.7 effective audience: the union over the books claiming the doc. The
247+
// gated book's `include: admin_*` rule claims `admin_runbook`, so a
248+
// non-holder is denied — on either spelling.
249+
const { rest, protocol } = setupCached();
250+
const plural = await getItem(rest, 'docs', 'admin_runbook');
251+
expect(plural.statusCode).toBe(403);
252+
expect(protocol.getMetaItemCached).not.toHaveBeenCalled();
253+
254+
const singular = await getItem(rest, 'doc', 'admin_runbook');
255+
expect(singular.statusCode).toBe(403);
256+
});
257+
258+
it('an unclaimed doc still reads (org default) — the gate narrows, it does not close the surface', async () => {
259+
const { rest } = setupCached();
260+
// `intro` is claimed by no book → effective audience `org` → a signed-in
261+
// caller may read it. Without this the three assertions above would be
262+
// satisfied by a fix that denied every doc/book read.
263+
expect((await getItem(rest, 'docs', 'intro')).statusCode).toBe(200);
264+
expect((await getItem(rest, 'books', 'manual')).statusCode).toBe(200);
265+
});
266+
267+
it('positive control: a non-gated type still takes the cached branch, ETag and all', async () => {
268+
// The bypass is only correct if it bypasses exactly the gated types. A fix
269+
// that disabled the cache wholesale would satisfy every assertion above and
270+
// silently cost every other metadata read its validator.
271+
const { rest, protocol } = setupCached();
272+
const res = await getItem(rest, 'views', 'account_list');
273+
274+
expect(protocol.getMetaItemCached).toHaveBeenCalledTimes(1);
275+
expect(protocol.getMetaItem).not.toHaveBeenCalled();
276+
expect(res.header.mock.calls.map((c: any[]) => c[0])).toContain('ETag');
277+
278+
// …and the singular spelling of that same non-gated type, so the fix is
279+
// "normalize", not "move the doc/book hole onto some other type".
280+
const { rest: rest2, protocol: protocol2 } = setupCached();
281+
await getItem(rest2, 'view', 'account_list');
282+
expect(protocol2.getMetaItemCached).toHaveBeenCalledTimes(1);
283+
});
284+
285+
it('the price of the bypass, pinned rather than hidden: gated reads carry no ETag', async () => {
286+
const { rest } = setupCached();
287+
const res = await getItem(rest, 'books', 'manual');
288+
289+
expect(res.statusCode).toBe(200);
290+
expect(res.header.mock.calls.map((c: any[]) => c[0])).not.toContain('ETag');
291+
});
292+
});

0 commit comments

Comments
 (0)