-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathcheck-wildcard-fallthrough.mjs
More file actions
494 lines (446 loc) · 22 KB
/
Copy pathcheck-wildcard-fallthrough.mjs
File metadata and controls
494 lines (446 loc) · 22 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
#!/usr/bin/env node
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Namespace-wildcard fall-through guard (#4116).
*
* ## What it guards
*
* A handler mounted on a wildcard path — `app.all('/api/v1/auth/*', h)` — claims
* an entire namespace. Hono runs every handler matching a path in REGISTRATION
* order and the first one to produce a Response wins. So if that handler is
* TERMINAL (it always answers, never `next()`s), every other route under the
* same prefix is reachable only when it happens to register FIRST — and plugin
* registration order is not a contract anyone declares or tests.
*
* That is not a hypothetical failure mode. It has now cost four separate fixes:
*
* - #2567 — anonymous-deny on raw `/data` held only while REST registered
* first, so a load-order change silently reopened anonymous data access.
* - #4018 — the standalone `/discovery` was shadowed by the dispatcher's, so
* a third publisher drifted unnoticed behind whoever won.
* - #4088 / #4092 — `AuthPlugin`'s `/api/v1/auth/*` catch-all swallowed
* `plugin-hono-server`'s `/auth/me/permissions`, which is the console's
* entire permission layer, unless the server plugin registered first.
* - cloud#923 — the same shape in `AuthProxyPlugin`. Its sibling repo had
* already ALSO hit it in staging: `apps/{objectos,cloud}/server/index.ts`
* record the Console 404ing on `/api/v1/auth/me/permissions`.
*
* Every one of those was found by hand, after the fact, by someone reading the
* code for an unrelated reason. Nothing in CI noticed any of them, and nothing
* would notice the fifth.
*
* ## Why a whole-repo scan rather than per-plugin tests
*
* #4092 and cloud#923 each shipped a driven test that pins ONE catch-all's
* fall-through. Those are worth having, but they are structurally incapable of
* covering the next wildcard someone mounts: a test can only drive the routes
* that existed the day it was written. What has never existed is the
* ENUMERATION — the question "which handlers in this repo claim a namespace, and
* is each one terminal?" has had no answer, so a new terminal catch-all lands
* with nothing to fail.
*
* This scan answers it, and follows `check-route-envelope.mjs` (#3843) in making
* a site discovered but NOT declared an ERROR rather than a default. Silently
* passing an unknown wildcard is what let the four above accumulate.
*
* ## Three states, deliberately
*
* yields — the handler takes a `next` and calls it. The scan verifies this
* from the AST, so the entry cannot rot: delete the `next()` and
* this fails.
* exempt — a REASON string for a wildcard that is SUPPOSED to own its whole
* namespace (a transport adapter's single entry point, a dev-only
* surface). Fall-through is then not asserted.
* ratchet — currently terminal, tracked, NOT blessed: the CURRENT state plus
* the issue that will fix it.
*
* There is no fourth state where nobody looked.
*
* ## Why AST, not regex
*
* "Does this handler call `next()`?" is a question about the handler's parameter
* binding, not about the text `next(` appearing nearby — a comment mentioning
* `next()`, or a `next` belonging to an inner closure, both fool a textual
* match, in the direction that PASSES while the defect ships. The parameter name
* is also not always `next`. Resolving the handler (inline arrow, or a
* `const handler = …` in the same file) and matching calls against its own
* second parameter's symbol name makes both problems disappear.
*
* ## Usage
*
* node scripts/check-wildcard-fallthrough.mjs # audit the repo
* node scripts/check-wildcard-fallthrough.mjs --list # print every site
* node scripts/check-wildcard-fallthrough.mjs --self-test # verify the checker
*/
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import ts from 'typescript';
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..');
/**
* Every namespace-claiming wildcard mount in the repo. Keyed by
* `<file>:<method> <pattern>` so two mounts in one file stay distinguishable.
* A site the scan finds that is not listed here fails — see the header.
*/
const MOUNTS = {
// ── Yields (verified from the AST) ───────────────────────────────────────
// Fixed by #4092. Was the terminal catch-all that swallowed
// `/auth/me/permissions`; now a 404 from better-auth means "not my path".
"packages/plugins/plugin-auth/src/auth-plugin.ts:all `${basePath}/*`": { yields: true },
// ADR-0069 D5 network gate — a real middleware: rejects out-of-range client
// IPs and otherwise `next()`s. Included because a wildcard that forgot to
// yield would be just as fatal here, and more silently: it fronts every auth
// route in the plugin.
"packages/plugins/plugin-auth/src/auth-plugin.ts:use `${basePath}/*`": { yields: true },
// Proxies the marketplace prefix upstream and yields what it does not own —
// already the shape #4092 had to retrofit onto AuthPlugin.
"packages/cloud-connection/src/marketplace-proxy-plugin.ts:all `${MARKETPLACE_PREFIX}/*`": { yields: true },
// Request-scoped middleware fronting every route (`serve`'s request log, the
// adapter's and plugin's own `*` middlewares). Terminal here would take down
// the entire surface, not one namespace.
'packages/cli/src/commands/serve.ts:use *': { yields: true },
'packages/plugins/plugin-hono-server/src/adapter.ts:use *': { yields: true },
'packages/plugins/plugin-hono-server/src/hono-plugin.ts:use *': { yields: true },
// ── Exempt: SUPPOSED to own the namespace ───────────────────────────────
// The dispatcher's single entry point. DELIBERATE — ADR-0076 OQ#9 verdict
// (#3576 / #3608): `dispatch()` is a gates+registry pipeline (env → identity →
// auth gate → membership → scope strip, then a first-match handler lookup),
// and splitting it into per-prefix mounts bypasses those cross-cutting stages
// (the #2852 RLS-leak class). Owning `${prefix}/*` is the design, not an
// oversight, so fall-through is not asserted. Reopen conditions on #3608.
'packages/adapters/hono/src/index.ts:all `${prefix}/*`': {
exempt: 'dispatcher single entry point — per-prefix mounts would bypass the gate stages (ADR-0076 OQ#9, #3576/#3608)',
},
// Third-party middleware factories (`hono/cors`). The handler is the return
// value of a call, so the scan cannot resolve it — and it does not need to:
// cors() yields by construction. Recorded rather than skipped so that
// swapping it for a hand-written middleware has to be reclassified.
'packages/adapters/hono/src/index.ts:use * (cors)': {
exempt: 'hono/cors middleware factory — yields internally, handler not resolvable at the call site',
},
'packages/plugins/plugin-hono-server/src/hono-plugin.ts:use * (cors)': {
exempt: 'hono/cors middleware factory — yields internally, handler not resolvable at the call site',
},
// SPA subtrees. A single-page app owns every path under its own mount point —
// that IS the SPA fallback contract (deep links must serve index.html rather
// than 404). Neither claims a shared API namespace: one is scoped to
// `${CONSOLE_PATH}`, the other is registered inside `listen()`, i.e. AFTER
// every plugin's `kernel:ready` routes, so it cannot shadow any of them.
'packages/cli/src/utils/console.ts:get `${CONSOLE_PATH}/*`': {
exempt: 'console SPA subtree — owning its own mount point is the SPA fallback contract',
},
'packages/plugins/plugin-hono-server/src/adapter.ts:get /* (serveStatic)': {
exempt: 'serveStatic SPA fallback, registered in listen() after every plugin route — cannot shadow',
},
// ── Ratchet: real, tracked, NOT blessed ─────────────────────────────────
//
// Both are the #4088 shape in an adapter that no in-repo package depends on,
// so nothing is broken today — but it ships, and ADR-0076's own trigger is an
// out-of-tree embedder (`../objectbase`'s gateway). An embedder mounting a
// route under either prefix hits exactly #4088. Found BY this scan; manual
// greps for the pattern had missed both. Tracked by #4117.
'packages/adapters/hono/src/index.ts:all `${prefix}/auth/*`': {
ratchet: '#4117 — terminal, same shape as #4088; adapter has no in-repo consumer',
},
};
/** HTTP-verb registrars plus `use`; anything that can claim a path pattern. */
const REGISTRARS = new Set(['all', 'use', 'get', 'post', 'put', 'delete', 'patch', 'options', 'head']);
/**
* Receivers we treat as an app/router. Deliberately loose — the cost of a false
* positive is one ledger line, the cost of a false negative is the whole point
* of the guard. `notFound`/`onError` are not registrars and never appear here.
*/
const APP_RECEIVER = /^(raw)?[aA]pp$|^(rawApp|honoApp|router|server)$/;
// ── Scan ─────────────────────────────────────────────────────────────────────
/** Recursively collect candidate source files under `packages/`. */
function discoverFiles() {
const out = [];
const skip = new Set(['node_modules', 'dist', 'build', '.turbo', '.next', 'coverage']);
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
if (skip.has(entry)) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) { walk(full); continue; }
if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue;
if (entry.includes('.test.') || entry.includes('.conformance.')) continue;
out.push(relative(ROOT, full).split(sep).join('/'));
}
};
walk(join(ROOT, 'packages'));
return out.sort();
}
/** The source text of a path argument, normalised for use as a ledger key. */
function patternText(node, src) {
if (ts.isStringLiteralLike(node)) return node.text;
if (ts.isTemplateExpression(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return node.getText(src);
}
return undefined;
}
/** True when a mounted pattern claims a namespace rather than one path. */
function isWildcard(pattern) {
if (!pattern) return false;
const unquoted = pattern.replace(/^`|`$/g, '');
return unquoted.endsWith('/*') || unquoted === '*' || unquoted === '/*';
}
/**
* Does `fn` take a continuation and call it?
*
* Matched against the second parameter's own name — not the text `next(` — so an
* inner closure's `next` or a comment cannot pass this.
*/
function callsContinuation(fn, src) {
if (!fn || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return false;
const param = fn.parameters?.[1];
if (!param || !ts.isIdentifier(param.name)) return false;
const name = param.name.text;
const rebinds = (node) =>
(ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node)) &&
node.parameters?.some((p) => ts.isIdentifier(p.name) && p.name.text === name);
let found = false;
const visit = (node) => {
if (found) return;
// An inner function taking its OWN parameter of the same name shadows ours;
// a call inside it is not this handler yielding.
if (rebinds(node)) return;
// `next()` — or `return next()` / `await next()`, same shape.
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === name) {
found = true;
return;
}
ts.forEachChild(node, visit);
};
ts.forEachChild(fn, visit);
return found;
}
/**
* Resolve the handler argument to a function node. Handles the two shapes the
* repo actually uses: an inline arrow, and a `const handler = async (c, next) …`
* declared in the same file and passed by name (cloud's AuthProxyPlugin mounts
* it that way, and a scan that only understood inline handlers would have
* reported that one as terminal).
*/
function resolveHandler(arg, src) {
if (!arg) return undefined;
if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) return arg;
if (!ts.isIdentifier(arg)) return undefined;
const wanted = arg.text;
let found;
const visit = (node) => {
if (found) return;
if (
ts.isVariableDeclaration(node) &&
ts.isIdentifier(node.name) &&
node.name.text === wanted &&
node.initializer &&
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
) {
found = node.initializer;
return;
}
ts.forEachChild(node, visit);
};
ts.forEachChild(src, visit);
return found;
}
/** Every wildcard mount in one file. */
function scanFile(file) {
const text = readFileSync(join(ROOT, file), 'utf8');
const src = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
const sites = [];
const visit = (node) => {
if (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
REGISTRARS.has(node.expression.name.text) &&
node.arguments.length >= 2
) {
const receiver = node.expression.expression;
const receiverName = ts.isIdentifier(receiver)
? receiver.text
: ts.isPropertyAccessExpression(receiver)
? receiver.name.text
: undefined;
if (receiverName && APP_RECEIVER.test(receiverName)) {
const pattern = patternText(node.arguments[0], src);
if (isWildcard(pattern)) {
const last = node.arguments[node.arguments.length - 1];
const handler = resolveHandler(last, src);
sites.push({
file,
method: node.expression.name.text,
pattern,
// A middleware FACTORY (`cors(…)`, `serveStatic(…)`) names the site
// apart from an inline handler in the same file with the same
// pattern — `hono-plugin.ts` has exactly that pair, and without a
// discriminator both collapse onto one ledger key. Chosen over the
// line number on purpose: the ledger must not churn every time code
// moves down a few lines.
factory: ts.isCallExpression(last) && ts.isIdentifier(last.expression) ? last.expression.text : undefined,
line: src.getLineAndCharacterOfPosition(node.getStart(src)).line + 1,
yields: callsContinuation(handler, src),
resolvedHandler: Boolean(handler),
});
}
}
}
ts.forEachChild(node, visit);
};
ts.forEachChild(src, visit);
return sites;
}
function scan() {
const sites = [];
for (const file of discoverFiles()) {
// Cheap pre-filter: parsing every .ts in the repo to find ~a dozen sites is
// needless work, and the tokens below are present in any real mount.
const text = readFileSync(join(ROOT, file), 'utf8');
if (!text.includes('/*`') && !text.includes("/*'") && !text.includes('/*"')) continue;
sites.push(...scanFile(file));
}
return sites;
}
/**
* Ledger key. Two sites CAN still collide (two inline `use('*')` in one file),
* and that is safe by construction: every site is checked against the shared
* declaration independently, so a colliding pair where only one yields still
* fails on the one that does not.
*/
const keyOf = (s) => `${s.file}:${s.method} ${s.pattern}${s.factory ? ` (${s.factory})` : ''}`;
// ── Audit ────────────────────────────────────────────────────────────────────
function audit() {
const sites = scan();
const problems = [];
const seen = new Set();
let yields = 0, exempt = 0, ratcheted = 0;
for (const site of sites) {
const key = keyOf(site);
seen.add(key);
const declared = MOUNTS[key];
if (!declared) {
problems.push(
`${site.file}:${site.line}\n` +
` ${site.method}('${site.pattern}') is NOT DECLARED.\n` +
` This handler claims the whole '${site.pattern}' namespace. Add it to MOUNTS in\n` +
` scripts/check-wildcard-fallthrough.mjs. If it yields to other routes (takes a\n` +
` \`next\` and calls it), declare { yields: true }. If it is SUPPOSED to own the\n` +
` namespace, declare \`exempt\` with the reason. If it is terminal and should not\n` +
` be, declare \`ratchet\` naming the issue — never leave one unaudited (#4116).`,
);
continue;
}
if (declared.exempt) { exempt++; continue; }
if (declared.ratchet) {
ratcheted++;
if (site.yields) {
problems.push(
`${site.file}:${site.line}\n` +
` ${site.method}('${site.pattern}') is declared as a ratchet (${declared.ratchet})\n` +
` but now YIELDS. Fixed? Replace the ratchet with { yields: true }.`,
);
}
continue;
}
if (declared.yields) {
yields++;
if (!site.resolvedHandler) {
problems.push(
`${site.file}:${site.line}\n` +
` ${site.method}('${site.pattern}') is declared { yields: true } but its handler\n` +
` could not be resolved, so the claim is unverifiable. Inline the handler, or\n` +
` declare \`exempt\`/\`ratchet\` with a reason.`,
);
} else if (!site.yields) {
problems.push(
`${site.file}:${site.line}\n` +
` ${site.method}('${site.pattern}') is declared { yields: true } but the handler\n` +
` never calls its continuation — it is TERMINAL. Every other route under\n` +
` '${site.pattern}' is now reachable only if it registers first (#4088). Take a\n` +
` \`next\` and call it for paths this handler does not own.`,
);
}
}
}
for (const key of Object.keys(MOUNTS)) {
if (!seen.has(key)) {
problems.push(
`${key}\n` +
` DECLARED but not found by the scan. Moved, renamed or deleted? Update MOUNTS.`,
);
}
}
if (problems.length) {
console.error('✗ wildcard fall-through guard (#4116)\n');
for (const p of problems) console.error(' ' + p + '\n');
console.error(`${problems.length} problem(s).`);
process.exit(1);
}
console.log(
`✓ wildcard fall-through: ${yields} yielding / ${ratcheted} ratcheted / ${exempt} exempt ` +
`(${sites.length} namespace-claiming mount${sites.length === 1 ? '' : 's'})`,
);
}
// ── Self-test ────────────────────────────────────────────────────────────────
function selfTest() {
const assert = (cond, msg) => { if (!cond) { console.error('✗ self-test: ' + msg); process.exit(1); } };
const parse = (code) => ts.createSourceFile('t.ts', code, ts.ScriptTarget.Latest, true);
// `isWildcard` — namespace claims vs single paths.
assert(isWildcard('/api/v1/auth/*'), 'plain wildcard');
assert(isWildcard('`${basePath}/*`'), 'template wildcard');
assert(isWildcard('/*') && isWildcard('*'), 'bare wildcard');
assert(!isWildcard('/api/v1/auth/me/permissions'), 'a concrete path is not a wildcard');
assert(!isWildcard('/api/v1/data/:object'), 'a param path is not a wildcard');
assert(!isWildcard(undefined), 'a non-literal pattern is not a wildcard');
// `callsContinuation` — the whole point is that this is not a text match.
const first = (code) => {
const src = parse(code);
let fn;
const visit = (n) => { if (!fn && (ts.isArrowFunction(n) || ts.isFunctionExpression(n))) fn = n; else ts.forEachChild(n, visit); };
ts.forEachChild(src, visit);
return { fn, src };
};
const yieldsIn = (code) => { const { fn, src } = first(code); return callsContinuation(fn, src); };
assert(yieldsIn('app.all("/a/*", async (c, next) => { await next(); });'), 'await next()');
assert(yieldsIn('app.all("/a/*", (c, next) => next());'), 'return next()');
assert(yieldsIn('app.all("/a/*", async (c, n) => { if (x) { await n(); } return r; });'), 'renamed continuation');
assert(!yieldsIn('app.all("/a/*", async (c) => r);'), 'no continuation parameter at all');
assert(
!yieldsIn('app.all("/a/*", async (c, next) => { /* we could next() here */ return r; });'),
'a comment mentioning next() must NOT count — this is the regex failure mode',
);
assert(
!yieldsIn('app.all("/a/*", async (c, next) => { const s = "call next() later"; return r; });'),
'a string mentioning next() must NOT count',
);
assert(
!yieldsIn('app.all("/a/*", async (c, next) => { items.forEach((next) => next()); return r; });'),
'an INNER binding shadowing the name must not count as yielding',
);
// `resolveHandler` — a handler passed by name must still be analysed, or the
// scan reports a yielding mount as terminal (cloud#923 mounts it that way).
{
const code = 'const handler = async (c, next) => { await next(); };\napp.all("/a/*", handler);';
const src = parse(code);
let call;
const visit = (n) => {
if (!call && ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'all') call = n;
else ts.forEachChild(n, visit);
};
ts.forEachChild(src, visit);
const fn = resolveHandler(call.arguments[1], src);
assert(fn, 'a handler passed by identifier resolves to its declaration');
assert(callsContinuation(fn, src), 'and its continuation call is seen');
}
// `keyOf` keeps two mounts in one file distinct.
assert(
keyOf({ file: 'a.ts', method: 'all', pattern: '/x/*' }) !== keyOf({ file: 'a.ts', method: 'use', pattern: '/x/*' }),
'method is part of the key',
);
console.log('✓ self-test: 15 cases');
}
if (process.argv.includes('--self-test')) selfTest();
else if (process.argv.includes('--list')) {
for (const s of scan()) {
console.log(`${s.yields ? 'yields ' : 'TERMINAL'} ${s.file}:${s.line} ${s.method}('${s.pattern}')${s.resolvedHandler ? '' : ' [handler unresolved]'}`);
}
} else audit();