From dfe05599d3792cf5b50c5a95759735383865cda1 Mon Sep 17 00:00:00 2001 From: murderteeth Date: Sun, 12 Apr 2026 17:33:25 +0000 Subject: [PATCH 1/3] JS: Add support for @vercel/node serverless functions This adds a framework model for Vercel serverless functions so that CodeQL's existing JavaScript security queries can detect vulnerabilities in handlers of the form export default function handler(req: VercelRequest, res: VercelResponse) { ... } Handlers are identified as the default export of a module whose first two parameters are typed as `VercelRequest`/`VercelResponse` from `@vercel/node`. The default-export constraint excludes private helpers that share the same signature. Type-based detection follows the same pattern already used by `NextReqResHandler` in `Next.qll`. The framework model covers: - Route handler recognition (default-exported typed handlers only) - Request input sources: `query`, `body`, `cookies`, and `url` (the last inherited from Node's `IncomingMessage`) - Named header accesses like `req.headers.host` and `req.headers.referer`, modelled as `Http::RequestHeaderAccess` so header-specific queries fire - Response sinks: `res.send`, `res.status(...).send`, `res.redirect` - Header definitions via `res.setHeader` Includes a library test exercising each model predicate (including a negative case for private helpers) and query consistency fixtures demonstrating end-to-end detection for js/reflected-xss, js/request-forgery, js/sql-injection, and js/command-line-injection. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../codeql/reusables/supported-frameworks.rst | 1 + .../change-notes/2026-04-12-vercel-node.md | 4 + javascript/ql/lib/javascript.qll | 1 + .../javascript/frameworks/VercelNode.qll | 200 ++++++++++++++++++ .../frameworks/vercel/HeaderDefinition.qll | 7 + .../frameworks/vercel/RedirectInvocation.qll | 7 + .../frameworks/vercel/RequestInputAccess.qll | 7 + .../frameworks/vercel/RequestSource.qll | 5 + .../vercel/ResponseSendArgument.qll | 7 + .../frameworks/vercel/ResponseSource.qll | 5 + .../frameworks/vercel/RouteHandler.qll | 3 + .../frameworks/vercel/src/notahandler.ts | 8 + .../frameworks/vercel/src/vercel.ts | 27 +++ .../frameworks/vercel/tests.expected | 22 ++ .../library-tests/frameworks/vercel/tests.ql | 7 + .../CommandInjection.expected | 11 + .../CWE-078/CommandInjection/vercel.ts | 9 + .../ReflectedXss/ReflectedXss.expected | 7 + .../ReflectedXssWithCustomSanitizer.expected | 2 + .../Security/CWE-079/ReflectedXss/vercel.ts | 6 + .../CWE-089/untyped/SqlInjection.expected | 11 + .../Security/CWE-089/untyped/vercel.ts | 10 + .../Security/CWE-918/RequestForgery.expected | 9 + .../query-tests/Security/CWE-918/vercel.ts | 7 + 24 files changed, 383 insertions(+) create mode 100644 javascript/ql/lib/change-notes/2026-04-12-vercel-node.md create mode 100644 javascript/ql/lib/semmle/javascript/frameworks/VercelNode.qll create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/HeaderDefinition.qll create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/RedirectInvocation.qll create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/RequestInputAccess.qll create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/RequestSource.qll create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/ResponseSendArgument.qll create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/ResponseSource.qll create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/RouteHandler.qll create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/src/notahandler.ts create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/src/vercel.ts create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/tests.expected create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/tests.ql create mode 100644 javascript/ql/test/query-tests/Security/CWE-078/CommandInjection/vercel.ts create mode 100644 javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/vercel.ts create mode 100644 javascript/ql/test/query-tests/Security/CWE-089/untyped/vercel.ts create mode 100644 javascript/ql/test/query-tests/Security/CWE-918/vercel.ts diff --git a/docs/codeql/reusables/supported-frameworks.rst b/docs/codeql/reusables/supported-frameworks.rst index 472e463cf79b..581f34f92271 100644 --- a/docs/codeql/reusables/supported-frameworks.rst +++ b/docs/codeql/reusables/supported-frameworks.rst @@ -197,6 +197,7 @@ and the CodeQL library pack ``codeql/javascript-all`` (`changelog void`, where + * the types are imported from the `@vercel/node` package. The Vercel runtime + * invokes the default export for every incoming HTTP request. + */ +module VercelNode { + /** + * A Vercel serverless function handler, identified as the default export of a + * module whose first two parameters are typed as `VercelRequest` and + * `VercelResponse` from `@vercel/node`. + * + * Since `@vercel/node` is commonly imported as a type-only import, handlers + * are recognised by their TypeScript parameter types. The default-export + * constraint excludes private helpers or test utilities that share the + * same signature. + */ + class RouteHandler extends Http::Servers::StandardRouteHandler, DataFlow::FunctionNode { + DataFlow::ParameterNode req; + DataFlow::ParameterNode res; + + RouteHandler() { + this = any(Module m).getAnExportedValue("default").getAFunctionValue() and + req = this.getParameter(0) and + res = this.getParameter(1) and + req.hasUnderlyingType("@vercel/node", "VercelRequest") and + res.hasUnderlyingType("@vercel/node", "VercelResponse") + } + + /** Gets the parameter that contains the request object. */ + DataFlow::ParameterNode getRequest() { result = req } + + /** Gets the parameter that contains the response object. */ + DataFlow::ParameterNode getResponse() { result = res } + } + + /** + * A Vercel request source, that is, the request parameter of a route handler. + */ + private class RequestSource extends Http::Servers::RequestSource { + RouteHandler rh; + + RequestSource() { this = rh.getRequest() } + + override RouteHandler getRouteHandler() { result = rh } + } + + /** + * A Vercel response source, that is, the response parameter of a route handler. + */ + private class ResponseSource extends Http::Servers::ResponseSource { + RouteHandler rh; + + ResponseSource() { this = rh.getResponse() } + + override RouteHandler getRouteHandler() { result = rh } + } + + /** + * A chained response, such as `res.status(200)`, `res.type('html')`, or `res.set(...)`. + * + * These methods return the response object and are commonly chained before `send` or `json`. + */ + private class ChainedResponseSource extends Http::Servers::ResponseSource { + RouteHandler rh; + + ChainedResponseSource() { + exists(ResponseSource src | + this = src.ref().getAMethodCall(["status", "type", "set"]) and + rh = src.getRouteHandler() + ) + } + + override RouteHandler getRouteHandler() { result = rh } + } + + /** + * An access to user-controlled input on a Vercel request. + * + * Covers `req.query`, `req.body`, `req.cookies`, and `req.url` (inherited + * from Node's `IncomingMessage`). Named-header accesses like `req.headers.host` + * are handled by `RequestHeaderAccess` below. + */ + private class RequestInputAccess extends Http::RequestInputAccess { + RouteHandler rh; + string kind; + + RequestInputAccess() { + exists(RequestSource src | rh = src.getRouteHandler() | + this = src.ref().getAPropertyRead("query") and kind = "parameter" + or + this = src.ref().getAPropertyRead("body") and kind = "body" + or + this = src.ref().getAPropertyRead("cookies") and kind = "cookie" + or + this = src.ref().getAPropertyRead("url") and kind = "url" + ) + or + exists(RequestHeaderAccess access | this = access | + rh = access.getRouteHandler() and + kind = "header" + ) + } + + override RouteHandler getRouteHandler() { result = rh } + + override string getKind() { result = kind } + } + + /** + * An access to a named header on a Vercel request, for example + * `req.headers.host` or `req.headers.referer`. + */ + private class RequestHeaderAccess extends Http::RequestHeaderAccess { + RouteHandler rh; + + RequestHeaderAccess() { + exists(RequestSource src | + this = src.ref().getAPropertyRead("headers").getAPropertyRead() and + rh = src.getRouteHandler() + ) + } + + override string getAHeaderName() { + result = this.(DataFlow::PropRead).getPropertyName().toLowerCase() + } + + override RouteHandler getRouteHandler() { result = rh } + + override string getKind() { result = "header" } + } + + /** + * An argument to `res.send(...)` on a Vercel response, including chained + * calls such as `res.status(200).send(...)`. + */ + private class ResponseSendArgument extends Http::ResponseSendArgument { + RouteHandler rh; + + ResponseSendArgument() { + exists(Http::Servers::ResponseSource src | + (src instanceof ResponseSource or src instanceof ChainedResponseSource) and + this = src.ref().getAMethodCall("send").getArgument(0) and + rh = src.getRouteHandler() + ) + } + + override RouteHandler getRouteHandler() { result = rh } + } + + /** + * A call to `res.redirect(...)` on a Vercel response. + */ + private class RedirectInvocation extends Http::RedirectInvocation, DataFlow::MethodCallNode { + RouteHandler rh; + + RedirectInvocation() { + exists(ResponseSource src | + this = src.ref().getAMethodCall("redirect") and + rh = src.getRouteHandler() + ) + } + + override DataFlow::Node getUrlArgument() { result = this.getLastArgument() } + + override RouteHandler getRouteHandler() { result = rh } + } + + /** + * A call to `res.setHeader(name, value)` on a Vercel response. + */ + private class SetHeader extends Http::ExplicitHeaderDefinition, DataFlow::CallNode { + RouteHandler rh; + + SetHeader() { + exists(ResponseSource src | + this = src.ref().getAMethodCall("setHeader") and + rh = src.getRouteHandler() + ) + } + + override RouteHandler getRouteHandler() { result = rh } + + override predicate definesHeaderValue(string headerName, DataFlow::Node headerValue) { + headerName = this.getArgument(0).getStringValue().toLowerCase() and + headerValue = this.getArgument(1) + } + + override DataFlow::Node getNameNode() { result = this.getArgument(0) } + } +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/HeaderDefinition.qll b/javascript/ql/test/library-tests/frameworks/vercel/HeaderDefinition.qll new file mode 100644 index 000000000000..496ee6e32e81 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/HeaderDefinition.qll @@ -0,0 +1,7 @@ +import javascript + +query predicate test_HeaderDefinition( + Http::HeaderDefinition hd, string name, VercelNode::RouteHandler rh +) { + hd.getRouteHandler() = rh and name = hd.getAHeaderName() +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/RedirectInvocation.qll b/javascript/ql/test/library-tests/frameworks/vercel/RedirectInvocation.qll new file mode 100644 index 000000000000..76e37d4a77f3 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/RedirectInvocation.qll @@ -0,0 +1,7 @@ +import javascript + +query predicate test_RedirectInvocation( + Http::RedirectInvocation call, DataFlow::Node url, VercelNode::RouteHandler rh +) { + call.getRouteHandler() = rh and url = call.getUrlArgument() +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/RequestInputAccess.qll b/javascript/ql/test/library-tests/frameworks/vercel/RequestInputAccess.qll new file mode 100644 index 000000000000..ac91695d500d --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/RequestInputAccess.qll @@ -0,0 +1,7 @@ +import javascript + +query predicate test_RequestInputAccess( + Http::RequestInputAccess ria, string kind, VercelNode::RouteHandler rh +) { + ria.getRouteHandler() = rh and kind = ria.getKind() +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/RequestSource.qll b/javascript/ql/test/library-tests/frameworks/vercel/RequestSource.qll new file mode 100644 index 000000000000..a8bdcc010200 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/RequestSource.qll @@ -0,0 +1,5 @@ +import javascript + +query predicate test_RequestSource(Http::Servers::RequestSource src, VercelNode::RouteHandler rh) { + src.getRouteHandler() = rh +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/ResponseSendArgument.qll b/javascript/ql/test/library-tests/frameworks/vercel/ResponseSendArgument.qll new file mode 100644 index 000000000000..7795e5cfb737 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/ResponseSendArgument.qll @@ -0,0 +1,7 @@ +import javascript + +query predicate test_ResponseSendArgument( + Http::ResponseSendArgument arg, VercelNode::RouteHandler rh +) { + arg.getRouteHandler() = rh +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/ResponseSource.qll b/javascript/ql/test/library-tests/frameworks/vercel/ResponseSource.qll new file mode 100644 index 000000000000..3a734b02f139 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/ResponseSource.qll @@ -0,0 +1,5 @@ +import javascript + +query predicate test_ResponseSource(Http::Servers::ResponseSource src, VercelNode::RouteHandler rh) { + src.getRouteHandler() = rh +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/RouteHandler.qll b/javascript/ql/test/library-tests/frameworks/vercel/RouteHandler.qll new file mode 100644 index 000000000000..a6ca13ec1e9a --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/RouteHandler.qll @@ -0,0 +1,3 @@ +import javascript + +query predicate test_RouteHandler(VercelNode::RouteHandler rh) { any() } diff --git a/javascript/ql/test/library-tests/frameworks/vercel/src/notahandler.ts b/javascript/ql/test/library-tests/frameworks/vercel/src/notahandler.ts new file mode 100644 index 000000000000..086673306b11 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/src/notahandler.ts @@ -0,0 +1,8 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +// A default-exported function that has VercelRequest/VercelResponse at +// positions 1 and 2, not 0 and 1. Vercel does not invoke it this way, +// so it must NOT be recognised as a route handler. +export default function notAHandler(ctx: unknown, req: VercelRequest, res: VercelResponse) { + res.send(req.query.name); +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/src/vercel.ts b/javascript/ql/test/library-tests/frameworks/vercel/src/vercel.ts new file mode 100644 index 000000000000..62ff97447724 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/src/vercel.ts @@ -0,0 +1,27 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +// A private helper with the same signature. Must NOT be recognised as a +// route handler, since Vercel only invokes the default export. +function internalHelper(req: VercelRequest, res: VercelResponse) { + res.send(req.query.name); +} + +export default function handler(req: VercelRequest, res: VercelResponse) { + // Request inputs + const q = req.query; // source: parameter + const b = req.body; // source: body + const c = req.cookies; // source: cookie + const u = req.url; // source: url (inherited from IncomingMessage) + const host = req.headers.host; // source: header (named) + const ref = req.headers.referer; // source: header (named) + + // Response header definition + res.setHeader("Content-Type", "text/html"); + + // Response send (direct and chained) + res.send(q); + res.status(200).send(b); + + // Redirect + res.redirect(req.query.url as string); +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/tests.expected b/javascript/ql/test/library-tests/frameworks/vercel/tests.expected new file mode 100644 index 000000000000..886ba9c5997c --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/tests.expected @@ -0,0 +1,22 @@ +test_RouteHandler +| src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +test_RequestSource +| src/vercel.ts:9:33:9:35 | req | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +test_ResponseSource +| src/vercel.ts:9:53:9:55 | res | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +| src/vercel.ts:23:3:23:17 | res.status(200) | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +test_HeaderDefinition +| src/vercel.ts:19:3:19:44 | res.set ... /html") | content-type | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +test_RedirectInvocation +| src/vercel.ts:26:3:26:39 | res.red ... string) | src/vercel.ts:26:16:26:38 | req.que ... string | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +test_RequestInputAccess +| src/vercel.ts:11:13:11:21 | req.query | parameter | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +| src/vercel.ts:12:13:12:20 | req.body | body | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +| src/vercel.ts:13:13:13:23 | req.cookies | cookie | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +| src/vercel.ts:14:13:14:19 | req.url | url | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +| src/vercel.ts:15:16:15:31 | req.headers.host | header | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +| src/vercel.ts:16:15:16:33 | req.headers.referer | header | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +| src/vercel.ts:26:16:26:24 | req.query | parameter | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +test_ResponseSendArgument +| src/vercel.ts:22:12:22:12 | q | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | +| src/vercel.ts:23:24:23:24 | b | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | diff --git a/javascript/ql/test/library-tests/frameworks/vercel/tests.ql b/javascript/ql/test/library-tests/frameworks/vercel/tests.ql new file mode 100644 index 000000000000..da4f5ff6c735 --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/tests.ql @@ -0,0 +1,7 @@ +import RouteHandler +import RequestSource +import ResponseSource +import RequestInputAccess +import HeaderDefinition +import ResponseSendArgument +import RedirectInvocation diff --git a/javascript/ql/test/query-tests/Security/CWE-078/CommandInjection/CommandInjection.expected b/javascript/ql/test/query-tests/Security/CWE-078/CommandInjection/CommandInjection.expected index f1d547bdfb1c..ddebc6baeaf3 100644 --- a/javascript/ql/test/query-tests/Security/CWE-078/CommandInjection/CommandInjection.expected +++ b/javascript/ql/test/query-tests/Security/CWE-078/CommandInjection/CommandInjection.expected @@ -110,6 +110,8 @@ | promisification.js:151:28:151:31 | code | promisification.js:141:18:141:25 | req.body | promisification.js:151:28:151:31 | code | This command line depends on a $@. | promisification.js:141:18:141:25 | req.body | user-provided value | | promisification.js:152:25:152:28 | code | promisification.js:141:18:141:25 | req.body | promisification.js:152:25:152:28 | code | This command line depends on a $@. | promisification.js:141:18:141:25 | req.body | user-provided value | | third-party-command-injection.js:6:21:6:27 | command | third-party-command-injection.js:5:20:5:26 | command | third-party-command-injection.js:6:21:6:27 | command | This command line depends on a $@. | third-party-command-injection.js:5:20:5:26 | command | user-provided value | +| vercel.ts:6:8:6:21 | "echo " + name | vercel.ts:5:16:5:24 | req.query | vercel.ts:6:8:6:21 | "echo " + name | This command line depends on a $@. | vercel.ts:5:16:5:24 | req.query | user-provided value | +| vercel.ts:6:8:6:21 | "echo " + name | vercel.ts:5:16:5:29 | req.query.name | vercel.ts:6:8:6:21 | "echo " + name | This command line depends on a $@. | vercel.ts:5:16:5:29 | req.query.name | user-provided value | edges | actions.js:8:9:8:13 | title | actions.js:9:16:9:20 | title | provenance | | | actions.js:8:17:8:57 | github. ... t.title | actions.js:8:9:8:13 | title | provenance | | @@ -340,6 +342,10 @@ edges | promisification.js:141:11:141:14 | code | promisification.js:152:25:152:28 | code | provenance | | | promisification.js:141:18:141:25 | req.body | promisification.js:141:11:141:14 | code | provenance | | | third-party-command-injection.js:5:20:5:26 | command | third-party-command-injection.js:6:21:6:27 | command | provenance | | +| vercel.ts:5:9:5:12 | name | vercel.ts:6:18:6:21 | name | provenance | | +| vercel.ts:5:16:5:24 | req.query | vercel.ts:5:9:5:12 | name | provenance | | +| vercel.ts:5:16:5:29 | req.query.name | vercel.ts:5:9:5:12 | name | provenance | | +| vercel.ts:6:18:6:21 | name | vercel.ts:6:8:6:21 | "echo " + name | provenance | | nodes | actions.js:8:9:8:13 | title | semmle.label | title | | actions.js:8:17:8:57 | github. ... t.title | semmle.label | github. ... t.title | @@ -591,6 +597,11 @@ nodes | promisification.js:152:25:152:28 | code | semmle.label | code | | third-party-command-injection.js:5:20:5:26 | command | semmle.label | command | | third-party-command-injection.js:6:21:6:27 | command | semmle.label | command | +| vercel.ts:5:9:5:12 | name | semmle.label | name | +| vercel.ts:5:16:5:24 | req.query | semmle.label | req.query | +| vercel.ts:5:16:5:29 | req.query.name | semmle.label | req.query.name | +| vercel.ts:6:8:6:21 | "echo " + name | semmle.label | "echo " + name | +| vercel.ts:6:18:6:21 | name | semmle.label | name | subpaths | promisification.js:116:32:116:34 | cmd | promisification.js:118:21:118:23 | cmd | promisification.js:117:29:117:35 | resolve [Return] [resolve-value] | promisification.js:117:16:119:10 | new Pro ... }) [PromiseValue] | | promisification.js:122:42:122:45 | code | promisification.js:116:32:116:34 | cmd | promisification.js:117:16:119:10 | new Pro ... }) [PromiseValue] | promisification.js:122:24:122:46 | createE ... e(code) [PromiseValue] | diff --git a/javascript/ql/test/query-tests/Security/CWE-078/CommandInjection/vercel.ts b/javascript/ql/test/query-tests/Security/CWE-078/CommandInjection/vercel.ts new file mode 100644 index 000000000000..73754cb88181 --- /dev/null +++ b/javascript/ql/test/query-tests/Security/CWE-078/CommandInjection/vercel.ts @@ -0,0 +1,9 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +import { exec } from "child_process"; + +export default function handler(req: VercelRequest, res: VercelResponse) { + const name = req.query.name as string; // $ Source + exec("echo " + name, (err, stdout) => { // $ Alert + res.send(stdout); + }); +} diff --git a/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXss.expected b/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXss.expected index bb92027f9f75..8ee7067977cd 100644 --- a/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXss.expected +++ b/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXss.expected @@ -74,6 +74,8 @@ | tst2.js:113:12:113:17 | unsafe | tst2.js:105:9:105:9 | p | tst2.js:113:12:113:17 | unsafe | Cross-site scripting vulnerability due to a $@. | tst2.js:105:9:105:9 | p | user-provided value | | tst3.js:6:12:6:12 | p | tst3.js:5:9:5:9 | p | tst3.js:6:12:6:12 | p | Cross-site scripting vulnerability due to a $@. | tst3.js:5:9:5:9 | p | user-provided value | | tst3.js:12:12:12:15 | code | tst3.js:11:32:11:39 | reg.body | tst3.js:12:12:12:15 | code | Cross-site scripting vulnerability due to a $@. | tst3.js:11:32:11:39 | reg.body | user-provided value | +| vercel.ts:5:24:5:51 | `

${ ... }

` | vercel.ts:5:31:5:39 | req.query | vercel.ts:5:24:5:51 | `

${ ... }

` | Cross-site scripting vulnerability due to a $@. | vercel.ts:5:31:5:39 | req.query | user-provided value | +| vercel.ts:5:24:5:51 | `

${ ... }

` | vercel.ts:5:31:5:44 | req.query.name | vercel.ts:5:24:5:51 | `

${ ... }

` | Cross-site scripting vulnerability due to a $@. | vercel.ts:5:31:5:44 | req.query.name | user-provided value | edges | ReflectedXss.js:7:33:7:45 | req.params.id | ReflectedXss.js:7:14:7:45 | "Unknow ... rams.id | provenance | | | ReflectedXss.js:16:31:16:39 | params.id | ReflectedXss.js:16:12:16:39 | "Unknow ... rams.id | provenance | | @@ -259,6 +261,8 @@ edges | tst3.js:11:9:11:12 | code | tst3.js:12:12:12:15 | code | provenance | | | tst3.js:11:16:11:74 | prettie ... bel" }) | tst3.js:11:9:11:12 | code | provenance | | | tst3.js:11:32:11:39 | reg.body | tst3.js:11:16:11:74 | prettie ... bel" }) | provenance | | +| vercel.ts:5:31:5:39 | req.query | vercel.ts:5:24:5:51 | `

${ ... }

` | provenance | | +| vercel.ts:5:31:5:44 | req.query.name | vercel.ts:5:24:5:51 | `

${ ... }

` | provenance | | nodes | ReflectedXss.js:7:14:7:45 | "Unknow ... rams.id | semmle.label | "Unknow ... rams.id | | ReflectedXss.js:7:33:7:45 | req.params.id | semmle.label | req.params.id | @@ -497,5 +501,8 @@ nodes | tst3.js:11:16:11:74 | prettie ... bel" }) | semmle.label | prettie ... bel" }) | | tst3.js:11:32:11:39 | reg.body | semmle.label | reg.body | | tst3.js:12:12:12:15 | code | semmle.label | code | +| vercel.ts:5:24:5:51 | `

${ ... }

` | semmle.label | `

${ ... }

` | +| vercel.ts:5:31:5:39 | req.query | semmle.label | req.query | +| vercel.ts:5:31:5:44 | req.query.name | semmle.label | req.query.name | subpaths | ReflectedXssGood3.js:139:24:139:26 | url | ReflectedXssGood3.js:68:22:68:26 | value | ReflectedXssGood3.js:108:10:108:23 | parts.join('') | ReflectedXssGood3.js:139:12:139:27 | escapeHtml3(url) | diff --git a/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXssWithCustomSanitizer.expected b/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXssWithCustomSanitizer.expected index fa2886fb0cd3..a538fcd8ee70 100644 --- a/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXssWithCustomSanitizer.expected +++ b/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXssWithCustomSanitizer.expected @@ -72,3 +72,5 @@ | tst2.js:113:12:113:17 | unsafe | Cross-site scripting vulnerability due to $@. | tst2.js:105:9:105:9 | p | user-provided value | | tst3.js:6:12:6:12 | p | Cross-site scripting vulnerability due to $@. | tst3.js:5:9:5:9 | p | user-provided value | | tst3.js:12:12:12:15 | code | Cross-site scripting vulnerability due to $@. | tst3.js:11:32:11:39 | reg.body | user-provided value | +| vercel.ts:5:24:5:51 | `

${ ... }

` | Cross-site scripting vulnerability due to $@. | vercel.ts:5:31:5:39 | req.query | user-provided value | +| vercel.ts:5:24:5:51 | `

${ ... }

` | Cross-site scripting vulnerability due to $@. | vercel.ts:5:31:5:44 | req.query.name | user-provided value | diff --git a/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/vercel.ts b/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/vercel.ts new file mode 100644 index 000000000000..dbd90171444d --- /dev/null +++ b/javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/vercel.ts @@ -0,0 +1,6 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +export default function handler(req: VercelRequest, res: VercelResponse) { + res.setHeader("Content-Type", "text/html"); + res.status(200).send(`

${req.query.name}

`); // $ Alert +} diff --git a/javascript/ql/test/query-tests/Security/CWE-089/untyped/SqlInjection.expected b/javascript/ql/test/query-tests/Security/CWE-089/untyped/SqlInjection.expected index f28fb93238d9..17d280d38096 100644 --- a/javascript/ql/test/query-tests/Security/CWE-089/untyped/SqlInjection.expected +++ b/javascript/ql/test/query-tests/Security/CWE-089/untyped/SqlInjection.expected @@ -156,6 +156,8 @@ | tst3.js:9:14:9:19 | query1 | tst3.js:8:16:8:34 | req.params.category | tst3.js:9:14:9:19 | query1 | This query string depends on a $@. | tst3.js:8:16:8:34 | req.params.category | user-provided value | | tst4.js:8:10:8:66 | 'SELECT ... d + '"' | tst4.js:8:46:8:60 | $routeParams.id | tst4.js:8:10:8:66 | 'SELECT ... d + '"' | This query string depends on a $@. | tst4.js:8:46:8:60 | $routeParams.id | user-provided value | | tst.js:10:10:10:64 | 'SELECT ... d + '"' | tst.js:10:46:10:58 | req.params.id | tst.js:10:10:10:64 | 'SELECT ... d + '"' | This query string depends on a $@. | tst.js:10:46:10:58 | req.params.id | user-provided value | +| vercel.ts:7:14:7:51 | "SELECT ... " + id | vercel.ts:6:14:6:22 | req.query | vercel.ts:7:14:7:51 | "SELECT ... " + id | This query string depends on a $@. | vercel.ts:6:14:6:22 | req.query | user-provided value | +| vercel.ts:7:14:7:51 | "SELECT ... " + id | vercel.ts:6:14:6:25 | req.query.id | vercel.ts:7:14:7:51 | "SELECT ... " + id | This query string depends on a $@. | vercel.ts:6:14:6:25 | req.query.id | user-provided value | edges | athena.js:9:11:9:19 | userQuery | athena.js:14:30:14:38 | userQuery | provenance | | | athena.js:9:11:9:19 | userQuery | athena.js:24:22:24:30 | userQuery | provenance | | @@ -620,6 +622,10 @@ edges | tst3.js:8:16:8:34 | req.params.category | tst3.js:7:7:7:12 | query1 | provenance | | | tst4.js:8:46:8:60 | $routeParams.id | tst4.js:8:10:8:66 | 'SELECT ... d + '"' | provenance | | | tst.js:10:46:10:58 | req.params.id | tst.js:10:10:10:64 | 'SELECT ... d + '"' | provenance | | +| vercel.ts:6:9:6:10 | id | vercel.ts:7:50:7:51 | id | provenance | | +| vercel.ts:6:14:6:22 | req.query | vercel.ts:6:9:6:10 | id | provenance | | +| vercel.ts:6:14:6:25 | req.query.id | vercel.ts:6:9:6:10 | id | provenance | | +| vercel.ts:7:50:7:51 | id | vercel.ts:7:14:7:51 | "SELECT ... " + id | provenance | | nodes | athena.js:9:11:9:19 | userQuery | semmle.label | userQuery | | athena.js:9:23:9:30 | req.body | semmle.label | req.body | @@ -1029,4 +1035,9 @@ nodes | tst4.js:8:46:8:60 | $routeParams.id | semmle.label | $routeParams.id | | tst.js:10:10:10:64 | 'SELECT ... d + '"' | semmle.label | 'SELECT ... d + '"' | | tst.js:10:46:10:58 | req.params.id | semmle.label | req.params.id | +| vercel.ts:6:9:6:10 | id | semmle.label | id | +| vercel.ts:6:14:6:22 | req.query | semmle.label | req.query | +| vercel.ts:6:14:6:25 | req.query.id | semmle.label | req.query.id | +| vercel.ts:7:14:7:51 | "SELECT ... " + id | semmle.label | "SELECT ... " + id | +| vercel.ts:7:50:7:51 | id | semmle.label | id | subpaths diff --git a/javascript/ql/test/query-tests/Security/CWE-089/untyped/vercel.ts b/javascript/ql/test/query-tests/Security/CWE-089/untyped/vercel.ts new file mode 100644 index 000000000000..b511f107747f --- /dev/null +++ b/javascript/ql/test/query-tests/Security/CWE-089/untyped/vercel.ts @@ -0,0 +1,10 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; +const mysql = require("mysql"); +const conn = mysql.createConnection({}); + +export default function handler(req: VercelRequest, res: VercelResponse) { + const id = req.query.id as string; // $ Source + conn.query("SELECT * FROM users WHERE id = " + id, (err: any, rows: any) => { // $ Alert + res.json(rows); + }); +} diff --git a/javascript/ql/test/query-tests/Security/CWE-918/RequestForgery.expected b/javascript/ql/test/query-tests/Security/CWE-918/RequestForgery.expected index 79383f585215..b3d939a30c5f 100644 --- a/javascript/ql/test/query-tests/Security/CWE-918/RequestForgery.expected +++ b/javascript/ql/test/query-tests/Security/CWE-918/RequestForgery.expected @@ -40,6 +40,8 @@ | serverSide.js:145:5:145:25 | axios.g ... dedUrl) | serverSide.js:139:19:139:31 | req.query.url | serverSide.js:145:15:145:24 | encodedUrl | The $@ of this request depends on a $@. | serverSide.js:145:15:145:24 | encodedUrl | URL | serverSide.js:139:19:139:31 | req.query.url | user-provided value | | serverSide.js:147:5:147:25 | axios.g ... pedUrl) | serverSide.js:139:19:139:31 | req.query.url | serverSide.js:147:15:147:24 | escapedUrl | The $@ of this request depends on a $@. | serverSide.js:147:15:147:24 | escapedUrl | URL | serverSide.js:139:19:139:31 | req.query.url | user-provided value | | serverSide.js:151:1:151:15 | request(custom) | serverSide.js:150:16:150:51 | require ... ource() | serverSide.js:151:9:151:14 | custom | The $@ of this request depends on a $@. | serverSide.js:151:9:151:14 | custom | URL | serverSide.js:150:16:150:51 | require ... ource() | user-provided value | +| vercel.ts:5:26:5:35 | fetch(url) | vercel.ts:4:15:4:23 | req.query | vercel.ts:5:32:5:34 | url | The $@ of this request depends on a $@. | vercel.ts:5:32:5:34 | url | URL | vercel.ts:4:15:4:23 | req.query | user-provided value | +| vercel.ts:5:26:5:35 | fetch(url) | vercel.ts:4:15:4:27 | req.query.url | vercel.ts:5:32:5:34 | url | The $@ of this request depends on a $@. | vercel.ts:5:32:5:34 | url | URL | vercel.ts:4:15:4:27 | req.query.url | user-provided value | edges | Request/app/api/proxy/route2.serverSide.ts:4:9:4:15 | { url } | Request/app/api/proxy/route2.serverSide.ts:4:11:4:13 | url | provenance | | | Request/app/api/proxy/route2.serverSide.ts:4:11:4:13 | url | Request/app/api/proxy/route2.serverSide.ts:5:27:5:29 | url | provenance | | @@ -147,6 +149,9 @@ edges | serverSide.js:146:31:146:35 | input | serverSide.js:146:24:146:36 | escape(input) | provenance | | | serverSide.js:150:7:150:12 | custom | serverSide.js:151:9:151:14 | custom | provenance | | | serverSide.js:150:16:150:51 | require ... ource() | serverSide.js:150:7:150:12 | custom | provenance | | +| vercel.ts:4:9:4:11 | url | vercel.ts:5:32:5:34 | url | provenance | | +| vercel.ts:4:15:4:23 | req.query | vercel.ts:4:9:4:11 | url | provenance | | +| vercel.ts:4:15:4:27 | req.query.url | vercel.ts:4:9:4:11 | url | provenance | | nodes | Request/app/api/proxy/route2.serverSide.ts:4:9:4:15 | { url } | semmle.label | { url } | | Request/app/api/proxy/route2.serverSide.ts:4:11:4:13 | url | semmle.label | url | @@ -277,4 +282,8 @@ nodes | serverSide.js:150:7:150:12 | custom | semmle.label | custom | | serverSide.js:150:16:150:51 | require ... ource() | semmle.label | require ... ource() | | serverSide.js:151:9:151:14 | custom | semmle.label | custom | +| vercel.ts:4:9:4:11 | url | semmle.label | url | +| vercel.ts:4:15:4:23 | req.query | semmle.label | req.query | +| vercel.ts:4:15:4:27 | req.query.url | semmle.label | req.query.url | +| vercel.ts:5:32:5:34 | url | semmle.label | url | subpaths diff --git a/javascript/ql/test/query-tests/Security/CWE-918/vercel.ts b/javascript/ql/test/query-tests/Security/CWE-918/vercel.ts new file mode 100644 index 000000000000..e383088489dc --- /dev/null +++ b/javascript/ql/test/query-tests/Security/CWE-918/vercel.ts @@ -0,0 +1,7 @@ +import type { VercelRequest, VercelResponse } from "@vercel/node"; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + const url = req.query.url as string; // $ Source[js/request-forgery] + const response = await fetch(url); // $ Alert[js/request-forgery] + res.json(await response.json()); +} From cff07342f5cbd5a3658be054e764b79ddac21bd4 Mon Sep 17 00:00:00 2001 From: murderteeth Date: Mon, 13 Apr 2026 17:31:29 +0000 Subject: [PATCH 2/3] Recognize legacy @now/node type aliases Extends the Vercel serverless handler detection to also match the deprecated Zeit-era @now/node package with NowRequest/NowResponse types. Per-review feedback from asgerf, these aliases still appear in real-world code. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ql/lib/semmle/javascript/frameworks/VercelNode.qll | 4 ++-- .../ql/test/library-tests/frameworks/vercel/src/now.ts | 7 +++++++ .../ql/test/library-tests/frameworks/vercel/tests.expected | 5 +++++ 3 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 javascript/ql/test/library-tests/frameworks/vercel/src/now.ts diff --git a/javascript/ql/lib/semmle/javascript/frameworks/VercelNode.qll b/javascript/ql/lib/semmle/javascript/frameworks/VercelNode.qll index 233f103825c6..e31d40dcf9a6 100644 --- a/javascript/ql/lib/semmle/javascript/frameworks/VercelNode.qll +++ b/javascript/ql/lib/semmle/javascript/frameworks/VercelNode.qll @@ -32,8 +32,8 @@ module VercelNode { this = any(Module m).getAnExportedValue("default").getAFunctionValue() and req = this.getParameter(0) and res = this.getParameter(1) and - req.hasUnderlyingType("@vercel/node", "VercelRequest") and - res.hasUnderlyingType("@vercel/node", "VercelResponse") + req.hasUnderlyingType(["@vercel/node", "@now/node"], ["NowRequest", "VercelRequest"]) and + res.hasUnderlyingType(["@vercel/node", "@now/node"], ["NowResponse", "VercelResponse"]) } /** Gets the parameter that contains the request object. */ diff --git a/javascript/ql/test/library-tests/frameworks/vercel/src/now.ts b/javascript/ql/test/library-tests/frameworks/vercel/src/now.ts new file mode 100644 index 000000000000..a8ac7020408c --- /dev/null +++ b/javascript/ql/test/library-tests/frameworks/vercel/src/now.ts @@ -0,0 +1,7 @@ +import type { NowRequest, NowResponse } from "@now/node"; + +// Legacy Zeit-era aliases. The model should treat these identically to +// the modern @vercel/node NowRequest -> VercelRequest, NowResponse -> VercelResponse. +export default function handler(req: NowRequest, res: NowResponse) { + res.send(req.query.name); +} diff --git a/javascript/ql/test/library-tests/frameworks/vercel/tests.expected b/javascript/ql/test/library-tests/frameworks/vercel/tests.expected index 886ba9c5997c..a2929999f235 100644 --- a/javascript/ql/test/library-tests/frameworks/vercel/tests.expected +++ b/javascript/ql/test/library-tests/frameworks/vercel/tests.expected @@ -1,8 +1,11 @@ test_RouteHandler +| src/now.ts:5:16:7:1 | functio ... ame);\\n} | | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | test_RequestSource +| src/now.ts:5:33:5:35 | req | src/now.ts:5:16:7:1 | functio ... ame);\\n} | | src/vercel.ts:9:33:9:35 | req | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | test_ResponseSource +| src/now.ts:5:50:5:52 | res | src/now.ts:5:16:7:1 | functio ... ame);\\n} | | src/vercel.ts:9:53:9:55 | res | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | | src/vercel.ts:23:3:23:17 | res.status(200) | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | test_HeaderDefinition @@ -10,6 +13,7 @@ test_HeaderDefinition test_RedirectInvocation | src/vercel.ts:26:3:26:39 | res.red ... string) | src/vercel.ts:26:16:26:38 | req.que ... string | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | test_RequestInputAccess +| src/now.ts:6:12:6:20 | req.query | parameter | src/now.ts:5:16:7:1 | functio ... ame);\\n} | | src/vercel.ts:11:13:11:21 | req.query | parameter | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | | src/vercel.ts:12:13:12:20 | req.body | body | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | | src/vercel.ts:13:13:13:23 | req.cookies | cookie | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | @@ -18,5 +22,6 @@ test_RequestInputAccess | src/vercel.ts:16:15:16:33 | req.headers.referer | header | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | | src/vercel.ts:26:16:26:24 | req.query | parameter | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | test_ResponseSendArgument +| src/now.ts:6:12:6:25 | req.query.name | src/now.ts:5:16:7:1 | functio ... ame);\\n} | | src/vercel.ts:22:12:22:12 | q | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | | src/vercel.ts:23:24:23:24 | b | src/vercel.ts:9:16:27:1 | functio ... ing);\\n} | From 47915328e6c91cfd3dfe411d83b7e44ba44d7e02 Mon Sep 17 00:00:00 2001 From: murderteeth Date: Mon, 13 Apr 2026 17:35:08 +0000 Subject: [PATCH 3/3] Address Copilot review nits Fixes US spelling (recognised -> recognized) across docs, QLDoc, change note, and test fixture comments. Clarifies the handler QLDoc to note sync/async support. Renames the supported-frameworks entry from "vercel" to "Vercel (@vercel/node)" to avoid implying broader platform coverage. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/codeql/reusables/supported-frameworks.rst | 2 +- javascript/ql/lib/change-notes/2026-04-12-vercel-node.md | 2 +- .../ql/lib/semmle/javascript/frameworks/VercelNode.qll | 9 +++++---- .../library-tests/frameworks/vercel/src/notahandler.ts | 2 +- .../ql/test/library-tests/frameworks/vercel/src/now.ts | 2 +- .../test/library-tests/frameworks/vercel/src/vercel.ts | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/codeql/reusables/supported-frameworks.rst b/docs/codeql/reusables/supported-frameworks.rst index 581f34f92271..930cdc6b629a 100644 --- a/docs/codeql/reusables/supported-frameworks.rst +++ b/docs/codeql/reusables/supported-frameworks.rst @@ -197,7 +197,7 @@ and the CodeQL library pack ``codeql/javascript-all`` (`changelog void`, where - * the types are imported from the `@vercel/node` package. The Vercel runtime - * invokes the default export for every incoming HTTP request. + * taking parameters `(req: VercelRequest, res: VercelResponse)`, where the + * types are imported from the `@vercel/node` package. The default export may + * be synchronous or `async`, and the Vercel runtime invokes it for every + * incoming HTTP request. */ module VercelNode { /** @@ -20,7 +21,7 @@ module VercelNode { * `VercelResponse` from `@vercel/node`. * * Since `@vercel/node` is commonly imported as a type-only import, handlers - * are recognised by their TypeScript parameter types. The default-export + * are recognized by their TypeScript parameter types. The default-export * constraint excludes private helpers or test utilities that share the * same signature. */ diff --git a/javascript/ql/test/library-tests/frameworks/vercel/src/notahandler.ts b/javascript/ql/test/library-tests/frameworks/vercel/src/notahandler.ts index 086673306b11..7bb1d903a84c 100644 --- a/javascript/ql/test/library-tests/frameworks/vercel/src/notahandler.ts +++ b/javascript/ql/test/library-tests/frameworks/vercel/src/notahandler.ts @@ -2,7 +2,7 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; // A default-exported function that has VercelRequest/VercelResponse at // positions 1 and 2, not 0 and 1. Vercel does not invoke it this way, -// so it must NOT be recognised as a route handler. +// so it must NOT be recognized as a route handler. export default function notAHandler(ctx: unknown, req: VercelRequest, res: VercelResponse) { res.send(req.query.name); } diff --git a/javascript/ql/test/library-tests/frameworks/vercel/src/now.ts b/javascript/ql/test/library-tests/frameworks/vercel/src/now.ts index a8ac7020408c..33a34d47e2a9 100644 --- a/javascript/ql/test/library-tests/frameworks/vercel/src/now.ts +++ b/javascript/ql/test/library-tests/frameworks/vercel/src/now.ts @@ -1,7 +1,7 @@ import type { NowRequest, NowResponse } from "@now/node"; // Legacy Zeit-era aliases. The model should treat these identically to -// the modern @vercel/node NowRequest -> VercelRequest, NowResponse -> VercelResponse. +// the modern @vercel/node types (NowRequest -> VercelRequest, NowResponse -> VercelResponse). export default function handler(req: NowRequest, res: NowResponse) { res.send(req.query.name); } diff --git a/javascript/ql/test/library-tests/frameworks/vercel/src/vercel.ts b/javascript/ql/test/library-tests/frameworks/vercel/src/vercel.ts index 62ff97447724..0dae664e2c44 100644 --- a/javascript/ql/test/library-tests/frameworks/vercel/src/vercel.ts +++ b/javascript/ql/test/library-tests/frameworks/vercel/src/vercel.ts @@ -1,6 +1,6 @@ import type { VercelRequest, VercelResponse } from "@vercel/node"; -// A private helper with the same signature. Must NOT be recognised as a +// A private helper with the same signature. Must NOT be recognized as a // route handler, since Vercel only invokes the default export. function internalHelper(req: VercelRequest, res: VercelResponse) { res.send(req.query.name);