From f96bd9ceca3f11b660fcd5382b3fcb206c2d6da7 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 28 Sep 2026 16:27:15 +0200 Subject: [PATCH 1/2] Recognise more ways handlers read their request The input map now follows: - the query string through a URL of the request: new URL(request.url), request.nextUrl, a request event's url, and their searchParams; - another name for the request (const r = req) or one of its namespaces (const b = req.body), including fields destructured from them; - a request event's request and a route context's params; - Hono's c.req.json(), query(), param() and header(), which are no longer inventoried as database calls; - Next.js Pages Router API routes (the default export of pages/api/**); - a validator schema declared elsewhere in the module and applied to the request. One imported from another module marks the endpoint's inputs as unresolved. Co-Authored-By: Claude Opus 5.5 --- src/map/entries.ts | 32 +++- src/map/extract.ts | 4 +- src/map/flows.ts | 88 +++++++-- src/map/inputs.ts | 152 ++++++++++++++-- src/map/routes.ts | 7 + src/map/sinks.ts | 14 +- tests/map/request-input-idioms.test.ts | 240 +++++++++++++++++++++++++ 7 files changed, 511 insertions(+), 26 deletions(-) create mode 100644 tests/map/request-input-idioms.test.ts diff --git a/src/map/entries.ts b/src/map/entries.ts index a9778c8d..56568d06 100644 --- a/src/map/entries.ts +++ b/src/map/entries.ts @@ -1,7 +1,8 @@ import type { Endpoint, Sink, TsModule } from './types.js'; import { hasExport, isFnLike, methodFromObjectArg, spanOf, unwindChain } from './ast.js'; import type { Bindings } from './bindings.js'; -import { functionNameFromPath, isRoutePath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js'; +import { functionNameFromPath, isPagesApiFile, isRoutePath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js'; +import { declarationOf } from './scope.js'; import { withCoordinates } from './coordinates.js'; import { inputsFromHandler, inputsFromValidator } from './inputs.js'; import { sinksFrom, type LocalSinks, type SinkContext } from './sinks.js'; @@ -14,6 +15,7 @@ const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', ' export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, bindings: Bindings, ctx: SinkContext): Omit[] { const out: Omit[] = []; const isServerActionsFile = fileHasUseServer(sf, ts); + const pagesApi = isPagesApiFile(ctx.owner); const visit = (node: any) => { if (ts.isVariableStatement(node) && hasExport(node, ts)) { @@ -62,6 +64,15 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, b } } + // (2d) Next.js Pages Router: the default export of a `pages/api/**` file handles that route, whether + // declared in place or exported by name (`export default handler`). + if (pagesApi) { + const handler = defaultExportedHandler(node, ts); + if (handler) { + out.push(handlerEntry(handler.name ?? 'default', 'default-export', handler.fn.parameters, handler.fn.body, ts, localSinks, bindings, ctx, spanOf(handler.fn))); + } + } + // (2c) Deno / WinterCG function entry: `Deno.serve(handler)` or `serve(handler)` — Supabase Edge // Functions, Base44 backend functions, Deno workers. These platforms have no router and no route // file: one handler per module, invoked by the function's NAME, so the endpoint's identity comes @@ -117,6 +128,21 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, b return out; } +/** The function a module's default export names, when it is one: `export default function`, `export default fn`. */ +function defaultExportedHandler(node: any, ts: TsModule): { name?: string; fn: any } | undefined { + const isDefault = (n: any) => Boolean(n.modifiers?.some((m: any) => m.kind === ts.SyntaxKind.DefaultKeyword)); + if (ts.isFunctionDeclaration(node) && hasExport(node, ts) && isDefault(node) && node.body) return { name: node.name?.text, fn: node }; + if (!ts.isExportAssignment(node) || node.isExportEquals) return undefined; + const target = node.expression; + if (isFnLike(target, ts)) return { fn: target }; + if (!ts.isIdentifier(target)) return undefined; + const declaration = declarationOf(target, ts); + const owner = declaration?.parent; + if (owner && ts.isFunctionDeclaration(owner) && owner.body) return { name: target.text, fn: owner }; + if (owner && ts.isVariableDeclaration(owner) && owner.initializer && isFnLike(owner.initializer, ts)) return { name: target.text, fn: owner.initializer }; + return undefined; +} + // Next server actions: a `'use server'` directive at the top of a module (whole file) or a function body. function fileHasUseServer(sf: any, ts: TsModule): boolean { const first = sf.statements?.[0]; @@ -148,7 +174,7 @@ function handlerEntry( : 'route-handler'; // A server action receives its payload as the first argument; a route handler receives a Request. const payloadStyle = kindLabel === 'server-action'; - const inputs = inputsFromHandler(params, body, ts, bindings, { + const { inputs, schemaUnresolved } = inputsFromHandler(params, body, ts, bindings, { payloadParam: payloadStyle, validatorSource: payloadStyle ? 'server-fn-data' : 'json-body', }); @@ -164,5 +190,7 @@ function handlerEntry( inputs, sinks, ...linkedFlows(body, params, inputs, sinks, ts, handlerInvocations(body, ts, bindings, ctx)), + // The handler validates its request with a schema declared in another module: its fields are unknown. + ...(schemaUnresolved ? { inputsResolved: false as const } : {}), }; } diff --git a/src/map/extract.ts b/src/map/extract.ts index c9414db1..3da7fabe 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -6,7 +6,7 @@ import { guessScriptKind } from './ast.js'; import { buildModuleBindings } from './bindings.js'; import { collectSources, detectDeploymentShapes, detectFramework, hasEntrySignal, type WalkStats } from './sources.js'; import { classifyServerSurface, surfaceNote } from './surface.js'; -import { functionNameFromPath, routeFromFilePath } from './routes.js'; +import { functionNameFromPath, isPagesApiFile, routeFromFilePath } from './routes.js'; import { collectLocalSinks } from './sinks.js'; import { createModuleGraph } from './module-graph.js'; import { isProvenFlow } from './coordinates.js'; @@ -65,7 +65,7 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac // Imports are collected from EVERY file, entry point or not: the data layer of an AI-built app // usually lives in a file with no handler in it, so a pre-filtered file is exactly where the // interesting dependency is imported. - if (!hasEntrySignal(text)) { + if (!hasEntrySignal(text) && !isPagesApiFile(relFile)) { preFiltered++; const scanned = scanFileImports(text, ts); if (scanned === null) importScanFailures++; // this file's imports are unknown, not empty diff --git a/src/map/flows.ts b/src/map/flows.ts index d3f70298..f6aaa655 100644 --- a/src/map/flows.ts +++ b/src/map/flows.ts @@ -29,6 +29,11 @@ interface Root { * of reachability but never of an exact, untransformed value. */ reassigned?: boolean; + /** + * The binding is a URL of the request (`new URL(request.url)`, `request.nextUrl`, an event's `url`). + * Its members are not request fields: only its `searchParams` read the query string. + */ + url?: boolean; } /** @@ -162,9 +167,9 @@ function linkFlows( const rootPath = new Roots(ts); // A binding assigned again after its declaration may no longer hold what it was declared with. const reassigned = reassignedDeclarations(bodyNode, ts); - const addRoot = (declaration: any, path: string, space?: AddressSpace, accessor = false, request = false, inherited = false) => { + const addRoot = (declaration: any, path: string, space?: AddressSpace, accessor = false, request = false, inherited = false, url = false) => { const changed = inherited || reassigned.has(declaration); - rootPath.add(declaration, { path, space, ...(accessor ? { accessor } : {}), ...(request ? { request } : {}), ...(changed ? { reassigned: true } : {}) }); + rootPath.add(declaration, { path, space, ...(accessor ? { accessor } : {}), ...(request ? { request } : {}), ...(changed ? { reassigned: true } : {}), ...(url ? { url } : {}) }); }; for (const [index, p] of (params ?? []).entries()) { if (!p?.name) continue; @@ -174,6 +179,9 @@ function linkFlows( for (const el of p.name.elements) { if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; const key = bindingKey(el, ts); + // A request event (SvelteKit, Astro) carries the request and its URL: `({ request, url })`. + if (index === 0 && key === 'request') { addRoot(el.name, '', undefined, false, true); continue; } + if (index === 0 && key === 'url') { addRoot(el.name, '', undefined, false, false, false, true); continue; } // A destructured request source (`{ body }`) is a container: its members ARE the paths. const container = key !== undefined && CONTAINER_KEYS.has(key); addRoot(el.name, container ? '' : key ?? el.name.text, container ? spaceOfKey(key) : undefined, container && ACCESSOR_NAMESPACES.has(key!)); @@ -188,6 +196,8 @@ function linkFlows( let cur = init; while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; if (!cur) return undefined; + if (isSearchParams(cur, ts, rootPath)) return { path: '', space: 'get', accessor: true }; + if (isRequestUrl(cur, ts, rootPath)) return { path: '', url: true }; if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { const m = cur.expression.name.text; if (['json', 'formData', 'text'].includes(m)) { @@ -212,11 +222,16 @@ function linkFlows( if (ts.isVariableDeclaration(n) && n.initializer) { const base = requestReadPath(n.initializer); if (base !== undefined) { - if (ts.isIdentifier(n.name)) addRoot(n.name, base.path, base.space, base.accessor === true, false, base.reassigned === true); + if (ts.isIdentifier(n.name)) addRoot(n.name, base.path, base.space, base.accessor === true, base.request === true, base.reassigned === true, base.url === true); else if (ts.isObjectBindingPattern(n.name)) { for (const el of n.name.elements) { if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; const key = bindingKey(el, ts); + // Off a URL of the request, only `searchParams` is request data. + if (base.url === true) { + if (key === 'searchParams') addRoot(el.name, '', 'get', true, false, base.reassigned === true); + continue; + } // A request namespace (`query`, `headers`, …) only when destructured from the request itself: // off a parsed body the same key is an ordinary field, and its members are paths under it. const namespace = base.request === true && key !== undefined && REQ_SOURCES.includes(key); @@ -572,10 +587,56 @@ function accessorRead(node: any, ts: TsModule, rootPath: Roots): Root | undefine while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; if (!cur || !ts.isCallExpression(cur) || !ts.isPropertyAccessExpression(cur.expression)) return undefined; const [name] = cur.arguments; - if (cur.expression.name.text !== 'get' || cur.arguments.length !== 1 || !name || !ts.isStringLiteralLike(name)) return undefined; - if (!isAccessor(cur.expression.expression, ts, rootPath)) return undefined; + if (cur.arguments.length !== 1 || !name || !ts.isStringLiteralLike(name)) return undefined; + const method = cur.expression.name.text; + const receiver = cur.expression.expression; + // Hono: `c.req.query('q')`, `c.req.param('id')`, `c.req.header('x-token')` on the handler's context. + const hono = HONO_ACCESSOR_SPACES[method]; + if (hono && ts.isPropertyAccessExpression(receiver) && receiver.name.text === 'req' && ts.isIdentifier(receiver.expression) + && rootPath.get(receiver.expression)?.request === true) { + return { path: normalizePath(name.text), space: hono }; + } + if (method !== 'get') return undefined; + // `new URL(request.url).searchParams.get('q')`: the query-string field `q`. + if (isSearchParams(receiver, ts, rootPath) && !ts.isIdentifier(receiver)) return { path: normalizePath(name.text), space: 'get' }; + if (!isAccessor(receiver, ts, rootPath)) return undefined; + + return pathFromTainted(receiver, ts, rootPath, [name.text]); +} + +/** Hono request accessors and the address space each one reads. */ +const HONO_ACCESSOR_SPACES: Record = { query: 'get', param: 'route-param', header: 'server' }; + +/** + * Whether `node` is a URL of the request: `new URL(request.url)` (also on a Hono context's `req`), + * `request.nextUrl`, or a binding that holds one. + */ +function isRequestUrl(node: any, ts: TsModule, rootPath: Roots): boolean { + let cur = node; + while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAwaitExpression(cur))) cur = cur.expression; + if (!cur) return false; + if (ts.isIdentifier(cur)) return rootPath.get(cur)?.url === true; + const isRequest = (e: any) => ts.isIdentifier(e) && rootPath.get(e)?.request === true; + if (ts.isPropertyAccessExpression(cur) && cur.name.text === 'nextUrl') return isRequest(cur.expression); + if (ts.isNewExpression(cur) && ts.isIdentifier(cur.expression) && cur.expression.text === 'URL') { + const [href] = cur.arguments ?? []; + if (!href || !ts.isPropertyAccessExpression(href) || href.name.text !== 'url') return false; + const owner = href.expression; + return isRequest(owner) || (ts.isPropertyAccessExpression(owner) && owner.name.text === 'req' && isRequest(owner.expression)); + } + return false; +} - return pathFromTainted(cur.expression.expression, ts, rootPath, [name.text]); +/** Whether `node` is the `searchParams` of a request URL, or a binding that holds them. */ +function isSearchParams(node: any, ts: TsModule, rootPath: Roots): boolean { + let cur = node; + while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; + if (!cur) return false; + if (ts.isIdentifier(cur)) { + const root = rootPath.get(cur); + return root?.accessor === true && root.space === 'get'; + } + return ts.isPropertyAccessExpression(cur) && cur.name.text === 'searchParams' && isRequestUrl(cur.expression, ts, rootPath); } /** @@ -614,7 +675,7 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Roots, trailing: str } if (!cur || !ts.isIdentifier(cur)) return undefined; const base = rootPath.get(cur); - if (base === undefined) return undefined; + if (base === undefined || base.url === true) return undefined; segs.push(...trailing); let space = base.space; // Drop a leading NAMESPACE segment (`req.body.webhookUrl` → `webhookUrl`). Input names — and the @@ -624,14 +685,19 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Roots, trailing: str // proven. // The dropped segment is exactly what names the address space, so capture it before discarding it — // losing it is what made `req.query.id` and `req.body.id` the same read. - if (base.request === true && base.path === '' && segs.length > 1 && REQ_SOURCES.includes(segs[0]!)) { - space = spaceOfKey(segs[0]!) ?? space; + // A bare namespace (`const b = req.body`) is that namespace's container: its members are the fields. + let namespace: string | undefined; + if (base.request === true && base.path === '' && segs.length > 0 && REQ_SOURCES.includes(segs[0]!)) { + namespace = segs[0]!; + space = spaceOfKey(namespace) ?? space; segs.shift(); } // Read bare, the request is still the request — which is what lets `const { headers } = request` // destructure a namespace, and `const { headers } = await request.json()` not. - const request = base.request === true && segs.length === 0 ? { request: true } : {}; + const request = base.request === true && namespace === undefined && segs.length === 0 ? { request: true } : {}; + // `const headers = request.headers` holds an accessor, so `headers.get('x')` reads the header `x`. + const accessor = namespace !== undefined && segs.length === 0 && ACCESSOR_NAMESPACES.has(namespace) ? { accessor: true } : {}; const reassigned = base.reassigned === true ? { reassigned: true } : {}; - return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space, ...request, ...reassigned }; + return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space, ...request, ...accessor, ...reassigned }; } diff --git a/src/map/inputs.ts b/src/map/inputs.ts index d497553d..08e08752 100644 --- a/src/map/inputs.ts +++ b/src/map/inputs.ts @@ -1,5 +1,5 @@ import type { FieldShape, InputField, InputSource, TsModule } from './types.js'; -import { bindingKey, rootIdentifier } from './ast.js'; +import { bindingKey, rootIdentifier, rootIdentifierNode } from './ast.js'; import { declarationOf } from './scope.js'; import { npmPackageOf, type Bindings } from './bindings.js'; import { addressSpaceOf, inputIdOf, runtimeCoordinate } from './coordinates.js'; @@ -24,11 +24,14 @@ export function inputsFromHandler( ts: TsModule, bindings: Bindings, opts: { payloadParam?: boolean; validatorSource?: InputSource } = {}, -): InputField[] { +): { inputs: InputField[]; schemaUnresolved: boolean } { // A validated schema inside a handler describes the request body — except for a payload-style entry // (a server action), where the schema describes the action's own argument. const schemaSource = opts.validatorSource ?? 'json-body'; - const schemaFields = zodObjectFields(body, ts, bindings); + const inline = zodObjectFields(body, ts, bindings); + // A schema declared outside the handler and applied to the request (`Schema.parse(await req.json())`). + const referenced = inline.length > 0 ? { fields: [], unresolved: false } : referencedSchemaFields(params, body, ts, bindings); + const schemaFields = inline.length > 0 ? inline : referenced.fields; const reads = requestMemberAccesses(params, body, ts, opts); // Keyed by IDENTITY — `:` — not by field name. A handler that reads `query.id` and @@ -49,7 +52,67 @@ export function inputsFromHandler( put(name, source ?? schemaSource, shape); } for (const { name, sources } of reads) for (const source of sources) put(name, source); - return [...byId.values()]; + return { inputs: [...byId.values()], schemaUnresolved: referenced.unresolved }; +} + +const SCHEMA_METHODS = new Set(['parse', 'safeParse', 'parseAsync', 'safeParseAsync']); + +/** + * Fields of a schema the handler applies to its request but declares elsewhere: `Schema.parse(x)` where + * `x` derives from a handler parameter. A schema declared in this file is read; one imported from + * another module cannot be, and is reported as `unresolved` so its fields count as unknown, not absent. + */ +function referencedSchemaFields(params: any, body: any, ts: TsModule, bindings: Bindings): { fields: FieldShape[]; unresolved: boolean } { + if (!body) return { fields: [], unresolved: false }; + const paramDecls = new Set(); + for (const p of params ?? []) { + if (!p?.name) continue; + if (ts.isIdentifier(p.name)) paramDecls.add(p.name); + else if (ts.isObjectBindingPattern(p.name)) for (const el of p.name.elements) if (ts.isIdentifier(el.name)) paramDecls.add(el.name); + } + // Rooted at a handler parameter, directly or through one local (`const raw = await req.json()`). + const fromParams = (e: any): boolean => { + const root = rootIdentifierNode(e, ts); + const declaration = root ? declarationOf(root, ts) : undefined; + if (declaration === undefined) return false; + if (paramDecls.has(declaration)) return true; + const owner = declaration.parent; + if (owner && ts.isVariableDeclaration(owner) && owner.name === declaration && owner.initializer) { + const inner = rootIdentifierNode(owner.initializer, ts); + const innerDeclaration = inner ? declarationOf(inner, ts) : undefined; + return innerDeclaration !== undefined && paramDecls.has(innerDeclaration); + } + return false; + }; + let fields: FieldShape[] = []; + let unresolved = false; + const visit = (n: any) => { + if (fields.length > 0) return; + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && SCHEMA_METHODS.has(n.expression.name.text) + && ts.isIdentifier(n.expression.expression) + && n.arguments.some((a: any) => fromParams(a))) { + const declaration = declarationOf(n.expression.expression, ts); + const owner = declaration?.parent; + if (owner && ts.isVariableDeclaration(owner) && owner.name === declaration && owner.initializer) { + const literal = findValidatorObject(owner.initializer, ts, bindings); + if (literal) fields = fieldsOfObject(literal, ts, bindings, ''); + else unresolved = true; + } else if (declaration !== undefined && isImported(declaration, ts)) { + unresolved = true; + } + } + ts.forEachChild(n, visit); + }; + visit(body); + return { fields, unresolved: fields.length === 0 && unresolved }; +} + +function isImported(declaration: any, ts: TsModule): boolean { + for (let cur = declaration?.parent; cur; cur = cur.parent) { + if (ts.isImportDeclaration(cur)) return true; + if (ts.isSourceFile(cur) || ts.isStatement(cur)) return false; + } + return false; } // Find the first validator `.object({...})` in a subtree — gated on the receiver tracing to a known @@ -151,9 +214,18 @@ function requestMemberAccesses( out.set(name, list); }; const p0 = params?.[0]; - // The request binding itself, so an inner function's own parameter of the same name is not the request. + // The request bindings, by declaration, so an inner function's own parameter of the same name is not the + // request: the handler's first parameter, a destructured `({ request })`, and aliases (`const r = req`). const reqDecl = p0 && ts.isIdentifier(p0.name) ? p0.name : undefined; - const isRequest = (e: any): boolean => reqDecl !== undefined && ts.isIdentifier(e) && declarationOf(e, ts) === reqDecl; + const requestDecls = new Set(reqDecl ? [reqDecl] : []); + const isRequest = (e: any): boolean => ts.isIdentifier(e) && requestDecls.has(declarationOf(e, ts)); + // A request as an object that carries the request API: the request itself, or a context's `req` (Hono). + const isRequestObject = (e: any): boolean => + isRequest(e) || (ts.isPropertyAccessExpression(e) && e.name.text === 'req' && isRequest(e.expression)); + // `new URL(request.url)`, `request.nextUrl`, a destructured `({ url })` and their aliases — the query + // string is read through their `searchParams`. + const urlDecls = new Set(); + const searchParamDecls = new Set(); // Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`), // mapped to the NAMESPACE each one came from. It has to be a map, not a set of names: with // `({ query: q })` the local is `q`, and matching the local against the literal 'query'/'params' @@ -167,9 +239,18 @@ function requestMemberAccesses( if (p0 && !reqDecl && ts.isObjectBindingPattern(p0.name)) { for (const el of p0.name.elements) { const key = bindingKey(el, ts); - if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) { - sourceNames.set(el.name, namespaceSource(key)); - } + if (!key || !ts.isIdentifier(el.name)) continue; + if (REQ_SOURCES.includes(key)) sourceNames.set(el.name, namespaceSource(key)); + // A request event (SvelteKit, Astro): `({ request, url })`. + else if (key === 'request') requestDecls.add(el.name); + else if (key === 'url') urlDecls.add(el.name); + } + } + // A route context after the request: `(request, { params })` (Next.js). + for (const p of (params ?? []).slice(1)) { + if (!p?.name || !ts.isObjectBindingPattern(p.name)) continue; + for (const el of p.name.elements) { + if (bindingKey(el, ts) === 'params' && ts.isIdentifier(el.name)) sourceNames.set(el.name, 'route-param'); } } const unwrap = (e: any): any => { @@ -186,13 +267,35 @@ function requestMemberAccesses( const inner = unwrap(e); return Boolean(inner && ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) && ['json', 'formData'].includes(inner.expression.name.text) && - isRequest(inner.expression.expression)); + isRequestObject(inner.expression.expression)); + }; + const isRequestUrl = (e: any): boolean => { + const cur = unwrap(e); + if (!cur) return false; + if (ts.isIdentifier(cur)) return urlDecls.has(declarationOf(cur, ts)); + if (ts.isPropertyAccessExpression(cur) && cur.name.text === 'nextUrl') return isRequest(cur.expression); + if (ts.isNewExpression(cur) && ts.isIdentifier(cur.expression) && cur.expression.text === 'URL') { + const [href] = cur.arguments ?? []; + return Boolean(href && ts.isPropertyAccessExpression(href) && href.name.text === 'url' && isRequestObject(href.expression)); + } + return false; + }; + const isSearchParams = (e: any): boolean => { + const cur = unwrap(e); + if (!cur) return false; + if (ts.isIdentifier(cur)) return searchParamDecls.has(declarationOf(cur, ts)); + return ts.isPropertyAccessExpression(cur) && cur.name.text === 'searchParams' && isRequestUrl(cur.expression); + }; + const literalArgument = (call: any): string | undefined => { + const [arg] = call.arguments ?? []; + return call.arguments?.length === 1 && arg && ts.isStringLiteral(arg) ? arg.text : undefined; }; // `request.headers.get` in `request.headers.get('x')` is a METHOD of the namespace, not a field of it. // Recording it would invent an input named `get` — a coordinate no request carries. const isCallee = (n: any): boolean => Boolean(n.parent && (ts.isCallExpression(n.parent) || ts.isNewExpression(n.parent)) && n.parent.expression === n); const ACCESSOR_SOURCES = new Set(['header', 'cookie', 'form-body']); + const HONO_ACCESSORS: Record = { query: 'query', param: 'route-param', header: 'header' }; const visit = (n: any) => { // . if (ts.isPropertyAccessExpression(n) && isReqSourceExpr(n.expression) && !isCallee(n)) { @@ -215,8 +318,37 @@ function requestMemberAccesses( && ACCESSOR_SOURCES.has(sourceOfExpr(n.expression.expression))) { record((n.arguments[0] as any).text, sourceOfExpr(n.expression.expression)); } + // `url.searchParams.get('q')` — the query string, read through a URL of the request. + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'get' + && isSearchParams(n.expression.expression)) { + const name = literalArgument(n); + if (name !== undefined) record(name, 'query'); + } + // Hono: `c.req.query('q')`, `c.req.param('id')`, `c.req.header('x-token')`. + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) + && ts.isPropertyAccessExpression(n.expression.expression) && n.expression.expression.name.text === 'req' + && isRequest(n.expression.expression.expression)) { + const source = HONO_ACCESSORS[n.expression.name.text]; + const name = literalArgument(n); + if (source && name !== undefined) record(name, source); + } if (ts.isVariableDeclaration(n) && n.initializer) { const init = unwrap(n.initializer); + if (ts.isIdentifier(n.name)) { + // const r = req → another name for the request. + if (isRequest(init)) requestDecls.add(n.name); + // const b = req.body → a namespace of the request, read under another name. + else if (ts.isPropertyAccessExpression(init) && isRequest(init.expression) && REQ_SOURCES.includes(init.name.text)) { + sourceNames.set(n.name, namespaceSource(init.name.text)); + } else if (isRequestUrl(init)) urlDecls.add(n.name); + else if (isSearchParams(init)) searchParamDecls.add(n.name); + } + // const { searchParams } = new URL(request.url) + if (ts.isObjectBindingPattern(n.name) && isRequestUrl(init)) { + for (const el of n.name.elements) { + if (bindingKey(el, ts) === 'searchParams' && ts.isIdentifier(el.name)) searchParamDecls.add(el.name); + } + } // const b = await request.json() → b is a request-input object from here on. if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name, bodyReadSource(n.initializer)); // const { query: q } = req → the SAME namespace capture as a destructured handler param, just one diff --git a/src/map/routes.ts b/src/map/routes.ts index 45fe9d30..44c3c965 100644 --- a/src/map/routes.ts +++ b/src/map/routes.ts @@ -70,6 +70,13 @@ export function routeFromFilePath(relFile: string): { route?: string; dynamic?: return { route: route.length > 1 ? route.replace(/\/+$/, '') : '/', dynamic }; } +/** Whether a file is a Next.js Pages Router API route (`pages/api/**`), whose default export is the handler. */ +export function isPagesApiFile(relFile: string): boolean { + const dirs = relFile.split(/[\\/]/).filter(Boolean).slice(0, -1); + const i = dirs.lastIndexOf('pages'); + return i !== -1 && dirs[i + 1] === 'api'; +} + /** Where a Nuxt server route's URL starts, from its directories; undefined outside `server/api` / `server/routes`. */ function nuxtRoot(dirs: string[]): string[] | undefined { const i = dirs.lastIndexOf('server'); diff --git a/src/map/sinks.ts b/src/map/sinks.ts index 9f1a7b6d..22315e2a 100644 --- a/src/map/sinks.ts +++ b/src/map/sinks.ts @@ -185,6 +185,17 @@ export function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: LocalSinks return dedupeSinks(sinks, ctx?.owner ?? ''); } +/** `c.req` where `c` is a function parameter: a request handler's context, not a database client. */ +function isContextRequest(node: any, ts: TsModule): boolean { + if (!ts.isPropertyAccessExpression(node) || node.name.text !== 'req' || !ts.isIdentifier(node.expression)) return false; + const declaration = declarationOf(node.expression, ts); + for (let cur = declaration?.parent; cur; cur = cur.parent) { + if (ts.isParameter(cur)) return true; + if (!ts.isBindingElement(cur) && !ts.isObjectBindingPattern(cur) && !ts.isArrayBindingPattern(cur)) return false; + } + return false; +} + /** * Whether a declaring identifier is bound at module level (an import, a top-level `require`), which is * where module bindings resolve names. A parameter or a function-local of the same name is not. @@ -306,7 +317,8 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings, ctx?: SinkCont } // db: raw `.query(` / `.execute(`. Any object can have a `.query` method, so an untraceable // receiver stays in the inventory with NO attribution — visible to a human, never auto-ruled. - if (method === 'query' || method === 'execute') { + // A handler context's request accessor (Hono's `c.req.query('q')`) reads the query string. + if ((method === 'query' || method === 'execute') && !isContextRequest(callee.expression, ts)) { const pkg = b.pkg ?? infer('db'); const attribution = attributionOf(b, pkg); push({ kind: 'db', ...dbApi(pkg, attribution), package: pkg, op: method, attribution, ...spanOf(n) }); diff --git a/tests/map/request-input-idioms.test.ts b/tests/map/request-input-idioms.test.ts new file mode 100644 index 00000000..a84ece15 --- /dev/null +++ b/tests/map/request-input-idioms.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { buildInputMap } from '../../src/map/index.js'; + +/** + * Common ways a handler reads its request: through the URL's query string, through another name for the + * request or one of its namespaces, through a framework's event or context object, and through a schema + * declared outside the handler. Each must name the field it reads, in the namespace it arrives in. + */ +async function mapOf(files: Record, deps: Record) { + const dir = mkdtempSync(path.join(tmpdir(), 'ps-idioms-')); + try { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: deps })); + for (const [file, source] of Object.entries(files)) { + mkdirSync(path.join(dir, path.dirname(file)), { recursive: true }); + writeFileSync(path.join(dir, file), source); + } + const { map } = await buildInputMap(dir, {}); + + return map!; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const EXEC = "import { exec } from 'node:child_process';\n"; +const NEXT = { next: '15.0.0' }; +const inputs = (endpoint: any): string[] => endpoint.inputs.map((i: any) => `${i.source}:${i.name}`).sort(); +const flow = (endpoint: any, name: string) => endpoint.flows.find((f: any) => f.input === name); + +const routeHandler = async (body: string, head = '') => { + const map = await mapOf({ 'app/api/run/route.ts': `${EXEC}${head}export async function GET(request: Request) {\n${body}\n return new Response('ok');\n}\n` }, NEXT); + return map.endpoints[0]!; +}; +const expressHandler = async (body: string) => { + const map = await mapOf({ 'src/server.ts': `${EXEC}import express from 'express';\nconst app = express();\napp.post('/run', (req, res) => {\n${body}\n res.end();\n});\n` }, { express: '4' }); + return map.endpoints[0]!; +}; + +describe('the query string through a URL of the request', () => { + it.each([ + ['read in place', " exec(new URL(request.url).searchParams.get('q'));"], + ['from a URL binding', " const url = new URL(request.url);\n exec(url.searchParams.get('q'));"], + ['from destructured searchParams', " const { searchParams } = new URL(request.url);\n exec(searchParams.get('q'));"], + ['from a searchParams binding', " const params = new URL(request.url).searchParams;\n exec(params.get('q'));"], + ])('names the query field when %s', async (_label, body) => { + const endpoint = await routeHandler(body); + + expect(inputs(endpoint)).toEqual(['query:q']); + expect(flow(endpoint, 'q')).toMatchObject({ confidence: 'exact-local', ruleGeneratable: true }); + }); + + it("reads Next.js's nextUrl", async () => { + const endpoint = await routeHandler(" exec(request.nextUrl.searchParams.get('q'));", "import type { NextRequest } from 'next/server';\n"); + + expect(inputs(endpoint)).toEqual(['query:q']); + expect(flow(endpoint, 'q')?.confidence).toBe('exact-local'); + }); + + it("reads a request event's url", async () => { + const map = await mapOf({ 'src/routes/api/run/+server.ts': `${EXEC}export async function GET({ url }) {\n exec(url.searchParams.get('q'));\n return new Response('ok');\n}\n` }, { '@sveltejs/kit': '2' }); + + expect(inputs(map.endpoints[0])).toEqual(['query:q']); + expect(flow(map.endpoints[0], 'q')?.confidence).toBe('exact-local'); + }); + + it('does not read a URL built from something other than the request', async () => { + const endpoint = await routeHandler(" exec(new URL(process.env.TARGET!).searchParams.get('q'));"); + + expect(inputs(endpoint)).toEqual([]); + }); + + it('does not treat other members of the request URL as request fields', async () => { + const endpoint = await routeHandler(" const { href } = await request.json();\n const url = new URL(request.url);\n console.log(href);\n exec(url.href);"); + + expect(flow(endpoint, 'href')?.confidence).toBe('heuristic'); + }); +}); + +describe('another name for the request or one of its namespaces', () => { + it('follows an alias of the request', async () => { + const endpoint = await expressHandler(' const r = req;\n exec(r.body.cmd);'); + + expect(inputs(endpoint)).toEqual(['body:cmd']); + expect(flow(endpoint, 'cmd')?.confidence).toBe('exact-local'); + }); + + it('follows an alias of the body', async () => { + const endpoint = await expressHandler(' const b = req.body;\n exec(b.cmd);'); + + expect(inputs(endpoint)).toEqual(['body:cmd']); + expect(flow(endpoint, 'cmd')?.confidence).toBe('exact-local'); + }); + + it('keeps the namespace of an aliased query object', async () => { + const endpoint = await expressHandler(' const q = req.query;\n exec(q.id);'); + + expect(inputs(endpoint)).toEqual(['query:id']); + expect(flow(endpoint, 'id')?.confidence).toBe('exact-local'); + }); + + it('proves a field destructured from the body', async () => { + const endpoint = await expressHandler(' const { cmd } = req.body;\n exec(cmd);'); + + expect(flow(endpoint, 'cmd')?.confidence).toBe('exact-local'); + }); + + it('reads a header through an alias of the headers', async () => { + const endpoint = await routeHandler(" const headers = request.headers;\n exec(headers.get('x-cmd'));"); + + expect(inputs(endpoint)).toEqual(['header:x-cmd']); + expect(flow(endpoint, 'x-cmd')?.confidence).toBe('exact-local'); + }); + + it('does not treat an alias of the body as the request itself', async () => { + const endpoint = await expressHandler(" const b = req.body;\n console.log(req.headers['x-cmd']);\n exec(b.headers.get('x-cmd'));"); + + expect(inputs(endpoint)).toContain('header:x-cmd'); + expect(flow(endpoint, 'x-cmd')?.confidence).toBe('heuristic'); + }); + + it('does not read a parsed body field named after a namespace as that namespace', async () => { + const endpoint = await expressHandler(" const b = req.body;\n const { query } = b;\n exec(query.id);"); + + expect(inputs(endpoint)).not.toContain('query:id'); + }); +}); + +describe('framework request objects', () => { + it("reads the request from a request event's `request`", async () => { + const map = await mapOf({ 'src/routes/api/run/+server.ts': `${EXEC}export async function POST({ request }) {\n exec(request.headers.get('x-cmd'));\n return new Response('ok');\n}\n` }, { '@sveltejs/kit': '2' }); + + expect(inputs(map.endpoints[0])).toEqual(['header:x-cmd']); + expect(flow(map.endpoints[0], 'x-cmd')?.confidence).toBe('exact-local'); + }); + + it("reads route params from Next.js's route context", async () => { + const map = await mapOf({ 'app/api/run/[id]/route.ts': `${EXEC}export async function GET(request: Request, { params }: { params: { id: string } }) {\n exec(params.id);\n return new Response('ok');\n}\n` }, NEXT); + + expect(inputs(map.endpoints[0])).toEqual(['route-param:id']); + }); + + it('reads the Hono request accessors', async () => { + const map = await mapOf({ + 'src/index.ts': `${EXEC}import { Hono } from 'hono';\nconst app = new Hono();\napp.post('/run', async (c) => {\n const body = await c.req.json();\n exec(body.cmd);\n exec(c.req.query('q'));\n exec(c.req.param('id'));\n exec(c.req.header('x-cmd'));\n return c.text('ok');\n});\n`, + }, { hono: '4' }); + const endpoint = map.endpoints[0]!; + + expect(inputs(endpoint)).toEqual(['header:x-cmd', 'json-body:cmd', 'query:q', 'route-param:id']); + for (const name of ['cmd', 'q', 'id', 'x-cmd']) { + expect(endpoint.flows.filter((f: any) => f.input === name && f.confidence === 'exact-local')).toHaveLength(1); + } + }); + + it("does not inventory a Hono query accessor as a database call", async () => { + const map = await mapOf({ + 'src/index.ts': "import { Hono } from 'hono';\nconst app = new Hono();\napp.get('/run', (c) => c.text(c.req.query('q') ?? ''));\n", + }, { hono: '4' }); + + expect(map.endpoints[0]!.sinks).toEqual([]); + }); + + it('still inventories a query call on a request accessor of an imported object', async () => { + const map = await mapOf({ + 'src/server.ts': "import express from 'express';\nimport sdk from 'data-sdk';\nconst app = express();\napp.post('/run', (req, res) => { sdk.req.query(req.body.sql); res.end(); });\n", + }, { express: '4', 'data-sdk': '1' }); + + expect(map.endpoints[0]!.sinks.map((s: any) => s.kind)).toEqual(['db']); + }); + + it('still inventories a query call on a client', async () => { + const map = await mapOf({ + 'src/server.ts': "import express from 'express';\nimport { Pool } from 'pg';\nconst pool = new Pool();\nconst app = express();\napp.post('/run', (req, res) => { pool.query(req.body.sql); res.end(); });\n", + }, { express: '4', pg: '8' }); + + expect(map.endpoints[0]!.sinks.map((s: any) => s.kind)).toEqual(['db']); + }); + + it('does not read an accessor call on the request itself as a Hono accessor', async () => { + const endpoint = await expressHandler(" exec(req.param('id'));"); + + expect(inputs(endpoint)).toEqual([]); + }); +}); + +describe('Next.js Pages Router API routes', () => { + it.each([ + ['a default-exported function', 'export default function handler(req, res) {\n exec(req.body.cmd);\n res.end();\n}\n'], + ['a named handler exported as default', 'const handler = (req, res) => {\n exec(req.body.cmd);\n res.end();\n};\nexport default handler;\n'], + ['a default-exported arrow', 'export default async (req, res) => {\n exec(req.body.cmd);\n res.end();\n};\n'], + ])('maps %s under pages/api to its route', async (_label, source) => { + const map = await mapOf({ 'pages/api/run.ts': EXEC + source }, NEXT); + + expect(map.endpoints).toHaveLength(1); + expect(map.endpoints[0]).toMatchObject({ entryKind: 'route-handler', route: '/api/run' }); + expect(flow(map.endpoints[0], 'cmd')?.confidence).toBe('exact-local'); + }); + + it('does not map the default export of a page', async () => { + const map = await mapOf({ 'pages/about.tsx': 'export default function About() { return null; }\n' }, NEXT); + + expect(map.endpoints).toEqual([]); + }); +}); + +describe('a schema declared outside the handler', () => { + it('reads the fields of a schema declared in the same module', async () => { + const endpoint = await routeHandler(' const data = Schema.parse(await request.json());\n exec(data.cmd);', "import { z } from 'zod';\nconst Schema = z.object({ cmd: z.string() });\n"); + + expect(inputs(endpoint)).toEqual(['json-body:cmd']); + expect(flow(endpoint, 'cmd')?.confidence).toBe('exact-local'); + expect(endpoint.inputsResolved).toBeUndefined(); + }); + + it('reads a schema applied to a local that holds the body', async () => { + const endpoint = await routeHandler(' const raw = await request.json();\n const data = Schema.parse(raw);\n exec(data.cmd);', "import { z } from 'zod';\nconst Schema = z.object({ cmd: z.string() });\n"); + + expect(inputs(endpoint)).toEqual(['json-body:cmd']); + }); + + it('reports the inputs of a handler validated by an imported schema as unresolved', async () => { + const endpoint = await routeHandler(' const data = Schema.parse(await request.json());\n exec(data.cmd);', "import { Schema } from '../../../lib/schema';\n"); + + expect(endpoint.inputsResolved).toBe(false); + }); + + it('does not read JSON.parse as a schema', async () => { + const endpoint = await routeHandler(' const data = JSON.parse(await request.text());\n exec(data.cmd);'); + + expect(endpoint.inputsResolved).toBeUndefined(); + }); + + it('does not read a schema applied to something other than the request', async () => { + const endpoint = await routeHandler(' const data = Schema.parse(defaults);\n exec(data.cmd);', "import { Schema } from '../../../lib/schema';\nconst defaults = {};\n"); + + expect(endpoint.inputsResolved).toBeUndefined(); + }); +}); From 809ac204f9e59558ed4a8cd88570051f9166c18d Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 28 Sep 2026 17:28:35 +0200 Subject: [PATCH 2/2] Carry reassignment through URL and accessor reads, and require the global URL A read through a URL of the request, its searchParams, a body read or a Hono accessor now keeps the reassignment marker of the binding it comes from, so it proves reachability but not an exact value once that binding has been assigned again. new URL(...) is only a URL of the request when URL is the global constructor (or globalThis.URL). A local or imported binding named URL can return anything, so reads through it are not request fields. Co-Authored-By: Claude Opus 5.5 --- src/map/flows.ts | 75 ++++++++++++-------- src/map/inputs.ts | 4 +- src/map/scope.ts | 7 ++ tests/map/request-input-idioms.test.ts | 95 ++++++++++++++++++++++++++ 4 files changed, 152 insertions(+), 29 deletions(-) diff --git a/src/map/flows.ts b/src/map/flows.ts index f6aaa655..42a02d73 100644 --- a/src/map/flows.ts +++ b/src/map/flows.ts @@ -1,6 +1,6 @@ import type { AddressSpace, ApiInvocation, ArgumentRole, DependencyInputFlow, Flow, InputField, Limitation, Sink, TsModule } from './types.js'; import { bindingKey, calleeName, isValueRead, lineOf, rootIdentifierNode } from './ast.js'; -import { declarationOf } from './scope.js'; +import { declarationOf, isGlobal } from './scope.js'; import { REQ_SOURCES } from './inputs.js'; import { addressSpaceOf } from './coordinates.js'; import { argumentRoleOf, CANDIDATE_FAMILIES } from './sinks.js'; @@ -196,16 +196,19 @@ function linkFlows( let cur = init; while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAsExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; if (!cur) return undefined; - if (isSearchParams(cur, ts, rootPath)) return { path: '', space: 'get', accessor: true }; - if (isRequestUrl(cur, ts, rootPath)) return { path: '', url: true }; + const params = searchParamsOrigin(cur, ts, rootPath); + if (params) return { path: '', space: 'get', accessor: true, ...carried(params) }; + const url = requestUrlOrigin(cur, ts, rootPath); + if (url) return { path: '', url: true, ...carried(url) }; if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { const m = cur.expression.name.text; if (['json', 'formData', 'text'].includes(m)) { const root = rootIdentifierNode(cur.expression.expression, ts); + const owner = root ? rootPath.get(root) : undefined; // A body read: whatever the field names turn out to be, they are addressed in `post`. - if (!root || !rootPath.get(root)) return undefined; + if (!owner) return undefined; - return m === 'formData' ? { path: '', space: 'post', accessor: true } : { path: '', space: 'post' }; + return m === 'formData' ? { path: '', space: 'post', accessor: true, ...carried(owner) } : { path: '', space: 'post', ...carried(owner) }; } if (['parse', 'safeParse', 'validate', 'cast'].includes(m)) { for (const a of cur.arguments) { @@ -592,13 +595,14 @@ function accessorRead(node: any, ts: TsModule, rootPath: Roots): Root | undefine const receiver = cur.expression.expression; // Hono: `c.req.query('q')`, `c.req.param('id')`, `c.req.header('x-token')` on the handler's context. const hono = HONO_ACCESSOR_SPACES[method]; - if (hono && ts.isPropertyAccessExpression(receiver) && receiver.name.text === 'req' && ts.isIdentifier(receiver.expression) - && rootPath.get(receiver.expression)?.request === true) { - return { path: normalizePath(name.text), space: hono }; + if (hono && ts.isPropertyAccessExpression(receiver) && receiver.name.text === 'req' && ts.isIdentifier(receiver.expression)) { + const context = rootPath.get(receiver.expression); + if (context?.request === true) return { path: normalizePath(name.text), space: hono, ...carried(context) }; } if (method !== 'get') return undefined; // `new URL(request.url).searchParams.get('q')`: the query-string field `q`. - if (isSearchParams(receiver, ts, rootPath) && !ts.isIdentifier(receiver)) return { path: normalizePath(name.text), space: 'get' }; + const params = ts.isIdentifier(receiver) ? undefined : searchParamsOrigin(receiver, ts, rootPath); + if (params) return { path: normalizePath(name.text), space: 'get', ...carried(params) }; if (!isAccessor(receiver, ts, rootPath)) return undefined; return pathFromTainted(receiver, ts, rootPath, [name.text]); @@ -607,36 +611,53 @@ function accessorRead(node: any, ts: TsModule, rootPath: Roots): Root | undefine /** Hono request accessors and the address space each one reads. */ const HONO_ACCESSOR_SPACES: Record = { query: 'get', param: 'route-param', header: 'server' }; +/** The reassignment marker of the binding a read derives from, to carry onto the read. */ +function carried(origin: Root): { reassigned?: true } { + return origin.reassigned === true ? { reassigned: true } : {}; +} + /** - * Whether `node` is a URL of the request: `new URL(request.url)` (also on a Hono context's `req`), - * `request.nextUrl`, or a binding that holds one. + * When `node` is a URL of the request — `new URL(request.url)` (also on a Hono context's `req`), + * `request.nextUrl`, or a binding that holds one — the binding it derives from. `URL` must be the + * global constructor: a local or imported `URL` can return anything. */ -function isRequestUrl(node: any, ts: TsModule, rootPath: Roots): boolean { +function requestUrlOrigin(node: any, ts: TsModule, rootPath: Roots): Root | undefined { let cur = node; - while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAwaitExpression(cur))) cur = cur.expression; - if (!cur) return false; - if (ts.isIdentifier(cur)) return rootPath.get(cur)?.url === true; - const isRequest = (e: any) => ts.isIdentifier(e) && rootPath.get(e)?.request === true; - if (ts.isPropertyAccessExpression(cur) && cur.name.text === 'nextUrl') return isRequest(cur.expression); - if (ts.isNewExpression(cur) && ts.isIdentifier(cur.expression) && cur.expression.text === 'URL') { + while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAwaitExpression(cur) || ts.isAsExpression(cur))) cur = cur.expression; + if (!cur) return undefined; + if (ts.isIdentifier(cur)) { + const root = rootPath.get(cur); + return root?.url === true ? root : undefined; + } + const request = (e: any): Root | undefined => { + let inner = e; + while (inner && (ts.isParenthesizedExpression(inner) || ts.isNonNullExpression(inner) || ts.isAsExpression(inner))) inner = inner.expression; + if (!inner || !ts.isIdentifier(inner)) return undefined; + const root = rootPath.get(inner); + return root?.request === true ? root : undefined; + }; + if (ts.isPropertyAccessExpression(cur) && cur.name.text === 'nextUrl') return request(cur.expression); + if (ts.isNewExpression(cur) && isGlobal(cur.expression, 'URL', ts)) { const [href] = cur.arguments ?? []; - if (!href || !ts.isPropertyAccessExpression(href) || href.name.text !== 'url') return false; + if (!href || !ts.isPropertyAccessExpression(href) || href.name.text !== 'url') return undefined; const owner = href.expression; - return isRequest(owner) || (ts.isPropertyAccessExpression(owner) && owner.name.text === 'req' && isRequest(owner.expression)); + if (ts.isPropertyAccessExpression(owner) && owner.name.text === 'req') return request(owner.expression) ?? request(owner); + return request(owner); } - return false; + return undefined; } -/** Whether `node` is the `searchParams` of a request URL, or a binding that holds them. */ -function isSearchParams(node: any, ts: TsModule, rootPath: Roots): boolean { +/** When `node` is the `searchParams` of a request URL, or a binding that holds them, the binding they derive from. */ +function searchParamsOrigin(node: any, ts: TsModule, rootPath: Roots): Root | undefined { let cur = node; - while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; - if (!cur) return false; + while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur) || ts.isAsExpression(cur))) cur = cur.expression; + if (!cur) return undefined; if (ts.isIdentifier(cur)) { const root = rootPath.get(cur); - return root?.accessor === true && root.space === 'get'; + return root?.accessor === true && root.space === 'get' ? root : undefined; } - return ts.isPropertyAccessExpression(cur) && cur.name.text === 'searchParams' && isRequestUrl(cur.expression, ts, rootPath); + if (!ts.isPropertyAccessExpression(cur) || cur.name.text !== 'searchParams') return undefined; + return requestUrlOrigin(cur.expression, ts, rootPath); } /** diff --git a/src/map/inputs.ts b/src/map/inputs.ts index 08e08752..db2721d9 100644 --- a/src/map/inputs.ts +++ b/src/map/inputs.ts @@ -1,6 +1,6 @@ import type { FieldShape, InputField, InputSource, TsModule } from './types.js'; import { bindingKey, rootIdentifier, rootIdentifierNode } from './ast.js'; -import { declarationOf } from './scope.js'; +import { declarationOf, isGlobal } from './scope.js'; import { npmPackageOf, type Bindings } from './bindings.js'; import { addressSpaceOf, inputIdOf, runtimeCoordinate } from './coordinates.js'; @@ -274,7 +274,7 @@ function requestMemberAccesses( if (!cur) return false; if (ts.isIdentifier(cur)) return urlDecls.has(declarationOf(cur, ts)); if (ts.isPropertyAccessExpression(cur) && cur.name.text === 'nextUrl') return isRequest(cur.expression); - if (ts.isNewExpression(cur) && ts.isIdentifier(cur.expression) && cur.expression.text === 'URL') { + if (ts.isNewExpression(cur) && isGlobal(cur.expression, 'URL', ts)) { const [href] = cur.arguments ?? []; return Boolean(href && ts.isPropertyAccessExpression(href) && href.name.text === 'url' && isRequestObject(href.expression)); } diff --git a/src/map/scope.ts b/src/map/scope.ts index 0202f084..2e201f6b 100644 --- a/src/map/scope.ts +++ b/src/map/scope.ts @@ -29,6 +29,13 @@ export function refersTo(id: any, declaration: any, ts: TsModule): boolean { return declaration !== undefined && declarationOf(id, ts) === declaration; } +/** Whether `node` names the global `name` (`URL`, `globalThis.URL`) rather than a binding of that name. */ +export function isGlobal(node: any, name: string, ts: TsModule): boolean { + if (ts.isIdentifier(node)) return node.text === name && declarationOf(node, ts) === undefined; + return ts.isPropertyAccessExpression(node) && node.name.text === name && ts.isIdentifier(node.expression) + && node.expression.text === 'globalThis' && declarationOf(node.expression, ts) === undefined; +} + /** The identifier nodes a binding name (`x`, `{ a, b: c }`, `[d, ...e]`) declares. */ export function boundIdentifiers(name: any, ts: TsModule): any[] { if (!name) return []; diff --git a/tests/map/request-input-idioms.test.ts b/tests/map/request-input-idioms.test.ts index a84ece15..78d26960 100644 --- a/tests/map/request-input-idioms.test.ts +++ b/tests/map/request-input-idioms.test.ts @@ -238,3 +238,98 @@ describe('a schema declared outside the handler', () => { expect(endpoint.inputsResolved).toBeUndefined(); }); }); + +describe('a reassigned binding behind a recognised read', () => { + // Reachability may still hold, but the value is no longer exactly the request field. + const notExact = (f: any) => { + expect(f).toBeDefined(); + expect(f.confidence).not.toBe('exact-local'); + }; + + it.each([ + ['a URL binding', " let url = new URL(request.url);\n url = new URL('https://example.test/?q=ls');\n exec(url.searchParams.get('q'));"], + ['a searchParams binding', " let params = new URL(request.url).searchParams;\n params = new URLSearchParams('q=ls');\n exec(params.get('q'));"], + ['destructured searchParams', " let { searchParams } = new URL(request.url);\n searchParams = new URLSearchParams('q=ls');\n exec(searchParams.get('q'));"], + ['the request, read through its URL', " request = new Request('https://example.test/?q=ls');\n exec(new URL(request.url).searchParams.get('q'));"], + ['the request, read through nextUrl', " request = {} as any;\n exec(request.nextUrl.searchParams.get('q'));"], + ['the request, bound to searchParams', " request = new Request('https://example.test/?q=ls');\n const params = new URL(request.url).searchParams;\n exec(params.get('q'));"], + ['the request, bound to a URL', " request = new Request('https://example.test/?q=ls');\n const url = new URL(request.url);\n exec(url.searchParams.get('q'));"], + ])('does not prove an exact query field through %s', async (_label, body) => { + notExact(flow(await routeHandler(body), 'q')); + }); + + it("does not prove an exact query field through a request event's reassigned url", async () => { + const map = await mapOf({ 'src/routes/api/run/+server.ts': `${EXEC}export async function GET({ url }) {\n url = new URL('https://example.test/?q=ls');\n exec(url.searchParams.get('q'));\n return new Response('ok');\n}\n` }, { '@sveltejs/kit': '2' }); + + notExact(flow(map.endpoints[0], 'q')); + }); + + it("does not prove an exact header through a request event's reassigned request", async () => { + const map = await mapOf({ 'src/routes/api/run/+server.ts': `${EXEC}export async function POST({ request }) {\n request = new Request('https://example.test/');\n exec(request.headers.get('x-cmd'));\n return new Response('ok');\n}\n` }, { '@sveltejs/kit': '2' }); + + notExact(flow(map.endpoints[0], 'x-cmd')); + }); + + it.each([ + ['an alias of the request', " let r = req;\n r = { body: { cmd: 'ls' } };\n exec(r.body.cmd);"], + ['an alias of the body', " let b = req.body;\n b = { cmd: 'ls' };\n exec(b.cmd);"], + ['the request behind an alias', " req = { body: { cmd: 'ls' } } as any;\n const r = req;\n exec(r.body.cmd);"], + ])('does not prove an exact body field through %s', async (_label, body) => { + notExact(flow(await expressHandler(body), 'cmd')); + }); + + it("does not prove an exact route param through a reassigned route context's params", async () => { + const map = await mapOf({ 'app/api/run/[id]/route.ts': `${EXEC}export async function GET(request: Request, { params }: { params: { id: string } }) {\n params = { id: 'fixed' };\n exec(params.id);\n return new Response('ok');\n}\n` }, NEXT); + + notExact(flow(map.endpoints[0], 'id')); + }); + + it('does not prove an exact Hono accessor read on a reassigned context', async () => { + const map = await mapOf({ + 'src/index.ts': `${EXEC}import { Hono } from 'hono';\nconst app = new Hono();\napp.get('/run', (c: any) => {\n c = { req: { query: () => 'ls' } };\n exec(c.req.query('q'));\n return new Response('ok');\n});\n`, + }, { hono: '4' }); + + notExact(flow(map.endpoints[0], 'q')); + }); + + it('does not prove an exact Hono body field on a reassigned context', async () => { + const map = await mapOf({ + 'src/index.ts': `${EXEC}import { Hono } from 'hono';\nconst app = new Hono();\napp.post('/run', async (c: any) => {\n c = { req: { json: async () => ({ cmd: 'ls' }) } };\n const body = await c.req.json();\n exec(body.cmd);\n return new Response('ok');\n});\n`, + }, { hono: '4' }); + + notExact(flow(map.endpoints[0], 'cmd')); + }); +}); + +describe('the URL constructor is the global one', () => { + it.each([ + ['a local class', 'class URL { searchParams = new Map([["q", "ls"]]); constructor(_: string) {} }\n'], + ['a local function', 'function URL(_: string): any { return { searchParams: new Map([["q", "ls"]]) }; }\n'], + ['an import', "import { URL } from './fake-url';\n"], + ])('does not read the query string through %s named URL', async (_label, head) => { + const endpoint = await routeHandler(" exec(new URL(request.url).searchParams.get('q'));", head); + + expect(inputs(endpoint)).toEqual([]); + expect(flow(endpoint, 'q')).toBeUndefined(); + }); + + it('does not read the query string through a URL binding built by a local URL', async () => { + const endpoint = await routeHandler(" const url = new URL(request.url);\n exec(url.searchParams.get('q'));", 'class URL { searchParams = new Map(); constructor(_: string) {} }\n'); + + expect(inputs(endpoint)).toEqual([]); + expect(flow(endpoint, 'q')).toBeUndefined(); + }); + + it('reads the query string through globalThis.URL', async () => { + const endpoint = await routeHandler(" exec(new globalThis.URL(request.url).searchParams.get('q'));"); + + expect(inputs(endpoint)).toEqual(['query:q']); + expect(flow(endpoint, 'q')?.confidence).toBe('exact-local'); + }); + + it('does not read globalThis.URL when globalThis is shadowed', async () => { + const endpoint = await routeHandler(" const globalThis = { URL: class { searchParams = new Map(); constructor(_: string) {} } };\n exec(new globalThis.URL(request.url).searchParams.get('q'));"); + + expect(inputs(endpoint)).toEqual([]); + }); +});