Skip to content

Commit 45bd2ab

Browse files
committed
fix(audits): keep the HEAD-on-GET allowance off defineScimRoute and catch extension-suffixed presenter imports
1 parent f60d692 commit 45bd2ab

4 files changed

Lines changed: 41 additions & 20 deletions

File tree

‎scripts/check-monorepo-boundaries.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ describe('package boundary command', () => {
8686
["export { listWidgetsContract } from '@/lib/api/contracts/widgets'", useCase, false],
8787
["import * as contracts from '@/lib/api/contracts/widgets'", useCase, false],
8888
["import { presentWidget } from '@/lib/api/server/widget-presenters'", useCase, false],
89+
["import { presentWidget } from '@/lib/api/server/widget-presenters.ts'", useCase, false],
8990
["import { run } from '@/lib/mothership/tools/handlers/run-code'", useCase, false],
9091
["import {} from '@/lib/mothership/tools/handlers/run-code'", useCase, false],
9192
["export {} from '@/lib/api/server/widget-presenters'", useCase, false],

‎scripts/check-monorepo-boundaries.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ function applicationViolation(
6767
) {
6868
return `application code imports a Copilot handler: ${specifier}`
6969
}
70-
if (specifier.startsWith('@/') && /(^|[/-])presenters?$/.test(specifier)) {
70+
if (specifier.startsWith('@/') && /(^|[/-])presenters?(\.[cm]?[jt]sx?)?$/.test(specifier)) {
7171
return `application code imports a presenter: ${specifier}`
7272
}
7373
if (matchesModule(specifier, '@/lib/api/contracts')) {

‎scripts/check-route-verbs.test.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('traced route declarations', () => {
1515
export const POST = (request: NextRequest, context?: Parameters<typeof handler>[1]) =>
1616
withIncomingGoSpan(request.headers, span, undefined, () => handler(request, context))`
1717
expect(wrappedRouteSites(source)).toEqual([
18-
{ verb: 'POST', optionsStart: source.indexOf('{') + 1 },
18+
{ verb: 'POST', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
1919
])
2020
})
2121
it('preserves a wrong verb so the contract comparison rejects it', () => {
@@ -30,8 +30,12 @@ const fallback = defineInternalJsonRoute({
3030
})
3131
export const POST = enabled ? handler : fallback`
3232
expect(wrappedRouteSites(source)).toEqual([
33-
{ verb: 'POST', optionsStart: source.indexOf('{') + 1 },
34-
{ verb: 'POST', optionsStart: source.indexOf('{', source.indexOf('const fallback')) + 1 },
33+
{ verb: 'POST', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
34+
{
35+
verb: 'POST',
36+
builder: 'defineInternalJsonRoute',
37+
optionsStart: source.indexOf('{', source.indexOf('const fallback')) + 1,
38+
},
3539
])
3640
})
3741
it('checks a builder handler exported through an export list', () => {
@@ -40,7 +44,7 @@ export const POST = enabled ? handler : fallback`
4044
})
4145
export { GET }`
4246
expect(wrappedRouteSites(source)).toEqual([
43-
{ verb: 'GET', optionsStart: source.indexOf('{') + 1 },
47+
{ verb: 'GET', builder: 'defineInternalJsonRoute', optionsStart: source.indexOf('{') + 1 },
4448
])
4549
})
4650
})
@@ -132,6 +136,11 @@ describe('HEAD handlers', () => {
132136
await checkSite(route, 'raw', 'HEAD', 'readContract', failures, false, loadModule)
133137
expect(failures).toEqual([])
134138
})
139+
it('rejects HEAD on a GET contract for defineScimRoute, which requires an exact method', async () => {
140+
const failures: string[] = []
141+
await checkSite(route, 'scim', 'HEAD', 'readContract', failures, false, loadModule)
142+
expect(failures).toHaveLength(1)
143+
})
135144
it('still rejects any other verb on a GET contract', async () => {
136145
const failures: string[] = []
137146
await checkSite(route, 'raw', 'POST', 'readContract', failures, false, loadModule)

‎scripts/check-route-verbs.ts‎

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,9 +147,11 @@ function topLevelBindings(statements: Statements): {
147147
}
148148

149149
/** Resolves exported handler references and tracing callbacks without importing server code. */
150-
export function wrappedRouteSites(source: string): Array<{ verb: string; optionsStart: number }> {
150+
export function wrappedRouteSites(
151+
source: string
152+
): Array<{ verb: string; builder: string; optionsStart: number }> {
151153
const statements = parse(source, { sourceType: 'module', plugins: ['typescript'] }).program.body
152-
const handlers = new Map<string, number>()
154+
const handlers = new Map<string, { builder: string; optionsStart: number }>()
153155
for (const statement of statements) {
154156
if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const') continue
155157
for (const declaration of statement.declarations) {
@@ -159,15 +161,18 @@ export function wrappedRouteSites(source: string): Array<{ verb: string; options
159161
continue
160162
const options = call.arguments[0]
161163
if (options?.type === 'ObjectExpression' && typeof options.start === 'number') {
162-
handlers.set(declaration.id.name, options.start + 1)
164+
handlers.set(declaration.id.name, {
165+
builder: call.callee.name,
166+
optionsStart: options.start + 1,
167+
})
163168
}
164169
}
165170
}
166171
const { locals, exports } = topLevelBindings(statements)
167-
const sites: Array<{ verb: string; optionsStart: number }> = []
172+
const sites: Array<{ verb: string; builder: string; optionsStart: number }> = []
168173
for (const { exported: verb, local } of exports) {
169174
if (!isVerb(verb)) continue
170-
const found = new Set<number>()
175+
const found = new Set<{ builder: string; optionsStart: number }>()
171176
const direct = handlers.get(local)
172177
if (direct !== undefined) found.add(direct)
173178
const visit = (value: unknown): void => {
@@ -178,13 +183,13 @@ export function wrappedRouteSites(source: string): Array<{ verb: string; options
178183
}
179184
const node = value as Record<string, unknown>
180185
if (node.type === 'Identifier' && typeof node.name === 'string') {
181-
const start = handlers.get(node.name)
182-
if (start !== undefined) found.add(start)
186+
const handler = handlers.get(node.name)
187+
if (handler !== undefined) found.add(handler)
183188
}
184189
Object.values(node).forEach(visit)
185190
}
186191
if (direct === undefined) visit(locals.get(local))
187-
for (const optionsStart of found) sites.push({ verb, optionsStart })
192+
for (const handler of found) sites.push({ verb, ...handler })
188193
}
189194
return sites
190195
}
@@ -347,8 +352,12 @@ function derivedPath(file: string): string {
347352
return ['/api', ...segments].join('/')
348353
}
349354

350-
/** Where a contract identifier is bound in a route: a builder's `contract:` key or a raw `parseRequest`. */
351-
type SiteKind = 'builder' | 'raw'
355+
/**
356+
* Where a contract identifier is bound in a route: a builder's `contract:` key or a raw
357+
* `parseRequest`. `scim` is `defineScimRoute`, the one builder that rejects a `HEAD` request
358+
* against a `GET` contract instead of serving it through `methodMatchesContract`.
359+
*/
360+
type SiteKind = 'builder' | 'scim' | 'raw'
352361

353362
/** One route file, as every site in it is checked against. */
354363
interface RouteFile {
@@ -426,13 +435,13 @@ export async function checkSite(
426435
}
427436

428437
const subject =
429-
kind === 'builder'
438+
kind !== 'raw'
430439
? `export const ${verb} is built from \`${identifier}\``
431440
: `export const ${verb} parses with \`${identifier}\``
432441
const method = contract.method.toUpperCase()
433-
if (method !== verb && !(verb === 'HEAD' && method === 'GET')) {
442+
if (method !== verb && !(verb === 'HEAD' && method === 'GET' && kind !== 'scim')) {
434443
failures.push(
435-
kind === 'builder'
444+
kind !== 'raw'
436445
? `${relative}: ${subject}, which declares ${contract.method} ${contract.path}. Next routes by the exported symbol, so ${verb} requests 500 and ${contract.method} requests 404.`
437446
: `${relative}: ${subject}, which declares ${contract.method} ${contract.path}. Clients calling through the contract send ${contract.method} and get a 405.`
438447
)
@@ -493,13 +502,14 @@ async function main() {
493502

494503
const directSites = [...source.matchAll(EXPORT_RE)].map((match) => ({
495504
verb: match[1],
505+
builder: match[2],
496506
optionsStart: (match.index ?? 0) + match[0].length,
497507
}))
498508
const sites =
499509
directSites.length === builderCalls
500510
? directSites
501511
: [...directSites, ...wrappedRouteSites(source)]
502-
for (const { verb, optionsStart } of sites) {
512+
for (const { verb, builder, optionsStart } of sites) {
503513
sitesInFile += 1
504514
const options = source.slice(optionsStart, optionsStart + OPTIONS_SCAN_CHARS)
505515

@@ -509,7 +519,8 @@ async function main() {
509519
continue
510520
}
511521
const identifier = contractKey[1]
512-
if (await checkSite(route, 'builder', verb, identifier, failures, verbose)) checked += 1
522+
const kind = builder === 'defineScimRoute' ? 'scim' : 'builder'
523+
if (await checkSite(route, kind, verb, identifier, failures, verbose)) checked += 1
513524
}
514525

515526
if (sitesInFile < builderCalls) {

0 commit comments

Comments
 (0)