From 8af63b532da72985dfffe62ac069c711a124f425 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Thu, 13 Aug 2026 16:15:27 +0200 Subject: [PATCH] map: keep the request namespace through renamed destructuring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while splitting extract.ts. The request namespace decides an input's runtime coordinate, but it was recorded as a SET of local names and then compared against the literal strings 'query'/'params'. With a renamed destructuring the local is the alias, so the comparison failed and the namespace was silently discarded: ({ query }) => query.doc -> get.doc (correct) ({ query: q }) => q.doc -> post.doc (WRONG: never matches a query-string attack) ({ params: p }) => p.id -> post.id (WORSE: a coordinate for a route param) The second case is the dangerous one: route params are not exposed by the runtime resolver at all, which is exactly why runtimeCoordinate returns null for them — and aliasing bypassed that guard, handing a rule compiler an address the engine can never resolve. This is the same failure class as attributing a sink by name: a coordinate that looks plausible and quietly does nothing. `sourceNames` is now a Map from local name to the namespace it was bound from, so an alias resolves to its true source. An aliased request-body read (`const b = await req.json()`) also keeps its precise source (json-body / form-body) instead of collapsing to a generic body. Covered for all five shapes plus the candidate consequence: the aliased route param yields no coordinate and therefore no candidate, while the four addressable ones still compile. Co-Authored-By: Claude Opus 4.8 --- src/map/inputs.ts | 27 ++++++++--- tests/map/aliased-namespace.test.ts | 73 +++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 tests/map/aliased-namespace.test.ts diff --git a/src/map/inputs.ts b/src/map/inputs.ts index 36e758a..b8e5ef8 100644 --- a/src/map/inputs.ts +++ b/src/map/inputs.ts @@ -122,14 +122,20 @@ function requestMemberAccesses( const out = new Map(); const p0 = params?.[0]; const reqName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : undefined; - // Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`). - const sourceNames = 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' + // discards the namespace — which silently mis-addresses the input (`post.doc` for a query-string + // field, and worse, a coordinate for a route param, which the resolver cannot address at all). + const sourceNames = new Map(); const payloadNames = new Set(); if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name.text); if (p0 && !reqName && 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.add(el.name.text); + if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) { + sourceNames.set(el.name.text, namespaceSource(key)); + } } } const unwrap = (e: any): any => { @@ -156,7 +162,7 @@ function requestMemberAccesses( if (ts.isVariableDeclaration(n) && n.initializer) { const init = unwrap(n.initializer); // const b = await request.json() → b is a request-input object from here on. - if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.add(n.name.text); + if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name.text, bodyReadSource(n.initializer)); // const { a, b } = | await request.json() if (ts.isObjectBindingPattern(n.name) && (isReqSourceExpr(init) || isBodyReadCall(n.initializer))) { const src = isBodyReadCall(n.initializer) ? bodyReadSource(n.initializer) : sourceOfExpr(init); @@ -180,13 +186,20 @@ function requestMemberAccesses( if (e.name.text === 'params') return 'route-param'; if (e.name.text === 'body') return 'body'; } + // The recorded namespace, so an ALIAS resolves correctly (`({ query: q }) => q.id` → query). if (ts.isIdentifier(e)) { - const key = [...sourceNames].includes(e.text) ? e.text : undefined; - if (key === 'query') return 'query'; - if (key === 'params') return 'route-param'; + const recorded = sourceNames.get(e.text); + if (recorded) return recorded; } return 'body'; } + + /** Map a request namespace key to the input source it implies. */ + function namespaceSource(key: string): InputSource { + if (key === 'query') return 'query'; + if (key === 'params') return 'route-param'; + return 'body'; + } function bodyReadSource(init: any): InputSource { const t = init?.getText?.() ?? ''; return /formData\s*\(/.test(t) ? 'form-body' : 'json-body'; diff --git a/tests/map/aliased-namespace.test.ts b/tests/map/aliased-namespace.test.ts new file mode 100644 index 0000000..3f89eef --- /dev/null +++ b/tests/map/aliased-namespace.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildInputMap } from '../../src/map/index.js'; + +// The request NAMESPACE decides the runtime coordinate, so it has to survive renamed destructuring. +// `({ query: q })` binds the local `q`; matching that local against the literal 'query' discarded the +// namespace, which mis-addressed the input two ways: +// - a query-string field got `post.doc` → a rule that can never match +// - an aliased ROUTE PARAM got `post.id` → a coordinate for something the resolver cannot address, +// defeating the whole point of returning null for route params. +let dir: string; +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'ps-alias-')); + mkdirSync(join(dir, 'src'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ dependencies: { express: '4' } })); + writeFileSync(join(dir, 'src', 'app.ts'), ` + import express from "express"; + import fs from "node:fs"; + const app = express(); + app.get("/plain", ({ query }, res) => { res.end(fs.readFileSync(query.doc)); }); + app.get("/renamed", ({ query: q }, res) => { res.end(fs.readFileSync(q.doc)); }); + app.get("/param/:id", ({ params: p }, res) => { res.end(fs.readFileSync(p.id)); }); + app.post("/bodyalias", ({ body: b }, res) => { res.end(fs.readFileSync(b.file)); }); + app.post("/nested", ({ query: q }, res) => { const { doc } = q; res.end(fs.readFileSync(doc)); }); + `); +}); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +const input = async (route: string, name: string) => { + const { map } = await buildInputMap(dir); + const ep = map!.endpoints.find((e) => e.route === route)!; + return { ep, field: ep.inputs.find((i) => i.name === name)! }; +}; + +describe('aliased request namespaces', () => { + it('keeps the namespace when the handler param is destructured plainly (control)', async () => { + const { field } = await input('/plain', 'doc'); + expect(field).toMatchObject({ source: 'query', runtimeParameter: 'get.doc' }); + }); + + it('keeps the namespace through a RENAMED destructuring', async () => { + const { field } = await input('/renamed', 'doc'); + expect(field).toMatchObject({ source: 'query', runtimeParameter: 'get.doc' }); + }); + + it('still refuses a coordinate for an aliased route param', async () => { + const { ep, field } = await input('/param/:id', 'id'); + expect(field.source).toBe('route-param'); + expect(field.runtimeParameter).toBeNull(); + // …and therefore cannot become a candidate, however strong the flow evidence is. + expect(ep.flows.filter((f) => f.input === 'id' && f.ruleGeneratable)).toEqual([]); + }); + + it('keeps an aliased body namespace', async () => { + const { field } = await input('/bodyalias', 'file'); + expect(field).toMatchObject({ source: 'body', runtimeParameter: 'post.file' }); + }); + + it('keeps the namespace when destructuring again from the alias', async () => { + const { field } = await input('/nested', 'doc'); + expect(field).toMatchObject({ source: 'query', runtimeParameter: 'get.doc' }); + }); + + it('compiles candidates for the addressable ones only', async () => { + const { map } = await buildInputMap(dir); + const got = map!.endpoints + .flatMap((e) => e.flows.filter((f) => f.ruleGeneratable).map((f) => `${e.route}:${f.input}`)) + .sort(); + expect(got).toEqual(['/bodyalias:file', '/nested:doc', '/plain:doc', '/renamed:doc']); + }); +});