Skip to content

JS: Support for Request and NextRequest #19184

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions javascript/ql/lib/change-notes/2025-04-11-nextrequest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
category: minorAnalysis
---
* Data passed to the [NextResponse](https://nextjs.org/docs/app/api-reference/functions/next-response) constructor is now treated as a sink for `js/reflected-xss`.
* Data received from [NextRequest](https://nextjs.org/docs/app/api-reference/functions/next-request) and [Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) is now treated as a remote user input `source`.
66 changes: 64 additions & 2 deletions javascript/ql/lib/semmle/javascript/frameworks/Next.qll
Original file line number Diff line number Diff line change
Expand Up @@ -213,10 +213,12 @@ module NextJS {
/**
* Gets a folder that contains API endpoints for a Next.js application.
* These API endpoints act as Express-like route-handlers.
* It matches both the Pages Router (`pages/api/`) Next.js 12 or earlier and
* the App Router (`app/api/`) Next.js 13+ structures.
*/
Folder apiFolder() {
result = getANextPackage().getFile().getParentContainer().getFolder("pages").getFolder("api")
or
result =
getANextPackage().getFile().getParentContainer().getFolder(["pages", "app"]).getFolder("api") or
result = apiFolder().getAFolder()
}

Expand Down Expand Up @@ -271,4 +273,64 @@ module NextJS {
override string getCredentialsKind() { result = "jwt key" }
}
}

/**
* A route handler for Next.js 13+ App Router API endpoints, which are defined by exporting
* HTTP method functions (like `GET`, `POST`, `PUT`, `DELETE`) from route.js files inside
* the `app/api/` directory.
*/
class NextAppRouteHandler extends DataFlow::FunctionNode, Http::Servers::StandardRouteHandler {
NextAppRouteHandler() {
exists(Module mod |
mod.getFile().getParentContainer() = apiFolder() or
mod.getFile().getStem() = "middleware"
|
this =
mod.getAnExportedValue([any(Http::RequestMethodName m), "middleware"]).getAFunctionValue()
)
}

/**
* Gets the request parameter, which is either a `NextRequest` object (from `next/server`) or a standard web `Request` object.
*/
DataFlow::SourceNode getRequest() { result = this.getParameter(0) }
}

/**
* A source of user-controlled data from a `NextRequest` object (from `next/server`) or a standard web `Request` object
* in a Next.js App Router route handler.
*/
class NextAppRequestSource extends Http::RequestInputAccess {
NextAppRouteHandler handler;
string kind;

NextAppRequestSource() {
(
this =
handler.getRequest().getAMethodCall(["json", "formData", "blob", "arrayBuffer", "text"])
or
this = handler.getRequest().getAPropertyRead("body")
) and
kind = "body"
or
this = handler.getRequest().getAPropertyRead(["url", "nextUrl"]) and
kind = "url"
or
this =
handler
.getRequest()
.getAPropertyRead("nextUrl")
.getAPropertyRead("searchParams")
.getAMemberCall("get") and
kind = "parameter"
or
this = handler.getRequest().getAPropertyRead("headers") and kind = "headers"
}

override string getKind() { result = kind }

override Http::RouteHandler getRouteHandler() { result = handler }

override string getSourceType() { result = "Next.js App Router request" }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ private class HeadersEntryPoint extends API::EntryPoint {
}

/**
* A call to the `Response` constructor.
* A call to the `Response` and `NextResponse` constructor.
*/
private class ResponseCall extends API::InvokeNode {
ResponseCall() { this = any(ResponseEntryPoint e).getANode().getAnInstantiation() }
ResponseCall() {
this = any(ResponseEntryPoint e).getANode().getAnInstantiation() or
this = API::moduleImport("next/server").getMember("NextResponse").getAnInstantiation()
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
| ReflectedXssContentTypes.js:39:13:39:35 | "FOO: " ... rams.id | ReflectedXssContentTypes.js:39:23:39:35 | req.params.id | ReflectedXssContentTypes.js:39:13:39:35 | "FOO: " ... rams.id | Cross-site scripting vulnerability due to a $@. | ReflectedXssContentTypes.js:39:23:39:35 | req.params.id | user-provided value |
| ReflectedXssContentTypes.js:70:12:70:34 | "FOO: " ... rams.id | ReflectedXssContentTypes.js:70:22:70:34 | req.params.id | ReflectedXssContentTypes.js:70:12:70:34 | "FOO: " ... rams.id | Cross-site scripting vulnerability due to a $@. | ReflectedXssContentTypes.js:70:22:70:34 | req.params.id | user-provided value |
| ReflectedXssGood3.js:139:12:139:27 | escapeHtml3(url) | ReflectedXssGood3.js:135:15:135:27 | req.params.id | ReflectedXssGood3.js:139:12:139:27 | escapeHtml3(url) | Cross-site scripting vulnerability due to a $@. | ReflectedXssGood3.js:135:15:135:27 | req.params.id | user-provided value |
| app/api/route.ts:5:18:5:21 | body | app/api/route.ts:2:24:2:33 | req.json() | app/api/route.ts:5:18:5:21 | body | Cross-site scripting vulnerability due to a $@. | app/api/route.ts:2:24:2:33 | req.json() | user-provided value |
| app/api/route.ts:13:18:13:21 | body | app/api/route.ts:2:24:2:33 | req.json() | app/api/route.ts:13:18:13:21 | body | Cross-site scripting vulnerability due to a $@. | app/api/route.ts:2:24:2:33 | req.json() | user-provided value |
| app/api/route.ts:25:18:25:21 | body | app/api/route.ts:2:24:2:33 | req.json() | app/api/route.ts:25:18:25:21 | body | Cross-site scripting vulnerability due to a $@. | app/api/route.ts:2:24:2:33 | req.json() | user-provided value |
| app/api/route.ts:29:25:29:28 | body | app/api/route.ts:2:24:2:33 | req.json() | app/api/route.ts:29:25:29:28 | body | Cross-site scripting vulnerability due to a $@. | app/api/route.ts:2:24:2:33 | req.json() | user-provided value |
| app/api/routeNextRequest.ts:7:20:7:23 | body | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | app/api/routeNextRequest.ts:7:20:7:23 | body | Cross-site scripting vulnerability due to a $@. | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | user-provided value |
| app/api/routeNextRequest.ts:15:20:15:23 | body | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | app/api/routeNextRequest.ts:15:20:15:23 | body | Cross-site scripting vulnerability due to a $@. | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | user-provided value |
| app/api/routeNextRequest.ts:27:20:27:23 | body | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | app/api/routeNextRequest.ts:27:20:27:23 | body | Cross-site scripting vulnerability due to a $@. | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | user-provided value |
| app/api/routeNextRequest.ts:31:27:31:30 | body | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | app/api/routeNextRequest.ts:31:27:31:30 | body | Cross-site scripting vulnerability due to a $@. | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | user-provided value |
| etherpad.js:11:12:11:19 | response | etherpad.js:9:16:9:30 | req.query.jsonp | etherpad.js:11:12:11:19 | response | Cross-site scripting vulnerability due to a $@. | etherpad.js:9:16:9:30 | req.query.jsonp | user-provided value |
| formatting.js:6:14:6:47 | util.fo ... , evil) | formatting.js:4:16:4:29 | req.query.evil | formatting.js:6:14:6:47 | util.fo ... , evil) | Cross-site scripting vulnerability due to a $@. | formatting.js:4:16:4:29 | req.query.evil | user-provided value |
| formatting.js:7:14:7:53 | require ... , evil) | formatting.js:4:16:4:29 | req.query.evil | formatting.js:7:14:7:53 | require ... , evil) | Cross-site scripting vulnerability due to a $@. | formatting.js:4:16:4:29 | req.query.evil | user-provided value |
Expand Down Expand Up @@ -128,6 +136,18 @@ edges
| ReflectedXssGood3.js:135:15:135:27 | req.params.id | ReflectedXssGood3.js:135:9:135:27 | url | provenance | |
| ReflectedXssGood3.js:139:24:139:26 | url | ReflectedXssGood3.js:68:22:68:26 | value | provenance | |
| ReflectedXssGood3.js:139:24:139:26 | url | ReflectedXssGood3.js:139:12:139:27 | escapeHtml3(url) | provenance | |
| app/api/route.ts:2:11:2:33 | body | app/api/route.ts:5:18:5:21 | body | provenance | |
| app/api/route.ts:2:11:2:33 | body | app/api/route.ts:13:18:13:21 | body | provenance | |
| app/api/route.ts:2:11:2:33 | body | app/api/route.ts:25:18:25:21 | body | provenance | |
| app/api/route.ts:2:11:2:33 | body | app/api/route.ts:29:25:29:28 | body | provenance | |
| app/api/route.ts:2:18:2:33 | await req.json() | app/api/route.ts:2:11:2:33 | body | provenance | |
| app/api/route.ts:2:24:2:33 | req.json() | app/api/route.ts:2:18:2:33 | await req.json() | provenance | |
| app/api/routeNextRequest.ts:4:9:4:31 | body | app/api/routeNextRequest.ts:7:20:7:23 | body | provenance | |
| app/api/routeNextRequest.ts:4:9:4:31 | body | app/api/routeNextRequest.ts:15:20:15:23 | body | provenance | |
| app/api/routeNextRequest.ts:4:9:4:31 | body | app/api/routeNextRequest.ts:27:20:27:23 | body | provenance | |
| app/api/routeNextRequest.ts:4:9:4:31 | body | app/api/routeNextRequest.ts:31:27:31:30 | body | provenance | |
| app/api/routeNextRequest.ts:4:16:4:31 | await req.json() | app/api/routeNextRequest.ts:4:9:4:31 | body | provenance | |
| app/api/routeNextRequest.ts:4:22:4:31 | req.json() | app/api/routeNextRequest.ts:4:16:4:31 | await req.json() | provenance | |
| etherpad.js:9:5:9:53 | response | etherpad.js:11:12:11:19 | response | provenance | |
| etherpad.js:9:16:9:30 | req.query.jsonp | etherpad.js:9:5:9:53 | response | provenance | |
| formatting.js:4:9:4:29 | evil | formatting.js:6:43:6:46 | evil | provenance | |
Expand Down Expand Up @@ -309,6 +329,20 @@ nodes
| ReflectedXssGood3.js:135:15:135:27 | req.params.id | semmle.label | req.params.id |
| ReflectedXssGood3.js:139:12:139:27 | escapeHtml3(url) | semmle.label | escapeHtml3(url) |
| ReflectedXssGood3.js:139:24:139:26 | url | semmle.label | url |
| app/api/route.ts:2:11:2:33 | body | semmle.label | body |
| app/api/route.ts:2:18:2:33 | await req.json() | semmle.label | await req.json() |
| app/api/route.ts:2:24:2:33 | req.json() | semmle.label | req.json() |
| app/api/route.ts:5:18:5:21 | body | semmle.label | body |
| app/api/route.ts:13:18:13:21 | body | semmle.label | body |
| app/api/route.ts:25:18:25:21 | body | semmle.label | body |
| app/api/route.ts:29:25:29:28 | body | semmle.label | body |
| app/api/routeNextRequest.ts:4:9:4:31 | body | semmle.label | body |
| app/api/routeNextRequest.ts:4:16:4:31 | await req.json() | semmle.label | await req.json() |
| app/api/routeNextRequest.ts:4:22:4:31 | req.json() | semmle.label | req.json() |
| app/api/routeNextRequest.ts:7:20:7:23 | body | semmle.label | body |
| app/api/routeNextRequest.ts:15:20:15:23 | body | semmle.label | body |
| app/api/routeNextRequest.ts:27:20:27:23 | body | semmle.label | body |
| app/api/routeNextRequest.ts:31:27:31:30 | body | semmle.label | body |
| etherpad.js:9:5:9:53 | response | semmle.label | response |
| etherpad.js:9:16:9:30 | req.query.jsonp | semmle.label | req.query.jsonp |
| etherpad.js:11:12:11:19 | response | semmle.label | response |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@
| ReflectedXssContentTypes.js:39:13:39:35 | "FOO: " ... rams.id | Cross-site scripting vulnerability due to $@. | ReflectedXssContentTypes.js:39:23:39:35 | req.params.id | user-provided value |
| ReflectedXssContentTypes.js:70:12:70:34 | "FOO: " ... rams.id | Cross-site scripting vulnerability due to $@. | ReflectedXssContentTypes.js:70:22:70:34 | req.params.id | user-provided value |
| ReflectedXssGood3.js:139:12:139:27 | escapeHtml3(url) | Cross-site scripting vulnerability due to $@. | ReflectedXssGood3.js:135:15:135:27 | req.params.id | user-provided value |
| app/api/route.ts:5:18:5:21 | body | Cross-site scripting vulnerability due to $@. | app/api/route.ts:2:24:2:33 | req.json() | user-provided value |
| app/api/route.ts:13:18:13:21 | body | Cross-site scripting vulnerability due to $@. | app/api/route.ts:2:24:2:33 | req.json() | user-provided value |
| app/api/route.ts:25:18:25:21 | body | Cross-site scripting vulnerability due to $@. | app/api/route.ts:2:24:2:33 | req.json() | user-provided value |
| app/api/route.ts:29:25:29:28 | body | Cross-site scripting vulnerability due to $@. | app/api/route.ts:2:24:2:33 | req.json() | user-provided value |
| app/api/routeNextRequest.ts:7:20:7:23 | body | Cross-site scripting vulnerability due to $@. | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | user-provided value |
| app/api/routeNextRequest.ts:15:20:15:23 | body | Cross-site scripting vulnerability due to $@. | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | user-provided value |
| app/api/routeNextRequest.ts:27:20:27:23 | body | Cross-site scripting vulnerability due to $@. | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | user-provided value |
| app/api/routeNextRequest.ts:31:27:31:30 | body | Cross-site scripting vulnerability due to $@. | app/api/routeNextRequest.ts:4:22:4:31 | req.json() | user-provided value |
| formatting.js:6:14:6:47 | util.fo ... , evil) | Cross-site scripting vulnerability due to $@. | formatting.js:4:16:4:29 | req.query.evil | user-provided value |
| formatting.js:7:14:7:53 | require ... , evil) | Cross-site scripting vulnerability due to $@. | formatting.js:4:16:4:29 | req.query.evil | user-provided value |
| live-server.js:6:13:6:50 | `<html> ... /html>` | Cross-site scripting vulnerability due to $@. | live-server.js:4:21:4:27 | req.url | user-provided value |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
export async function POST(req: Request) {
const body = await req.json(); // $ Source

new Response(body, {headers: { 'Content-Type': 'application/json' }});
new Response(body, {headers: { 'Content-Type': 'text/html' }}); // $ Alert

const headers2 = new Headers(req.headers);
headers2.append('Content-Type', 'application/json');
new Response(body, { headers: headers2 });

const headers3 = new Headers(req.headers);
headers3.append('Content-Type', 'text/html');
new Response(body, { headers: headers3 }); // $ Alert

const headers4 = new Headers({
...Object.fromEntries(req.headers),
'Content-Type': 'application/json'
});
new Response(body, { headers: headers4 });

const headers5 = new Headers({
...Object.fromEntries(req.headers),
'Content-Type': 'text/html'
});
new Response(body, { headers: headers5 }); // $ Alert

const headers = new Headers(req.headers);
headers.set('Content-Type', 'text/html');
return new Response(body, { headers }); // $ Alert
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
const body = await req.json(); // $ Source

new NextResponse(body, {headers: { 'Content-Type': 'application/json' }});
new NextResponse(body, {headers: { 'Content-Type': 'text/html' }}); // $ Alert

const headers2 = new Headers(req.headers);
headers2.append('Content-Type', 'application/json');
new NextResponse(body, { headers: headers2 });

const headers3 = new Headers(req.headers);
headers3.append('Content-Type', 'text/html');
new NextResponse(body, { headers: headers3 }); // $ Alert

const headers4 = new Headers({
...Object.fromEntries(req.headers),
'Content-Type': 'application/json'
});
new NextResponse(body, { headers: headers4 });

const headers5 = new Headers({
...Object.fromEntries(req.headers),
'Content-Type': 'text/html'
});
new NextResponse(body, { headers: headers5 }); // $ Alert

const headers = new Headers(req.headers);
headers.set('Content-Type', 'text/html');
return new NextResponse(body, { headers }); // $ Alert
}

This file was deleted.

25 changes: 0 additions & 25 deletions javascript/ql/test/query-tests/Security/CWE-918/Consistency.ql

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export async function POST(req: Request) {
const { url } = await req.json(); // $ Source[js/request-forgery]
const res = await fetch(url); // $ Alert[js/request-forgery] Sink[js/request-forgery]
return new Response(res.body, { headers: res.headers });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';

export async function POST(req: NextRequest) {
const { url } = await req.json(); // $ Source[js/request-forgery]
const res = await fetch(url); // $ Alert[js/request-forgery] Sink[js/request-forgery]
const data = await res.text();
return new NextResponse(data, { headers: res.headers });
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';

export async function middleware(req: NextRequest) {
const target = req.nextUrl // $ Source[js/request-forgery]
const target2 = target.searchParams.get('target'); // $ Source[js/request-forgery]
if (target) {
const res = await fetch(target) // $ Alert[js/request-forgery] Sink[js/request-forgery]
const data = await res.text()
return new NextResponse(data)
}
if (target2) {
const res = await fetch(target2); // $ Alert[js/request-forgery] Sink[js/request-forgery]
const data = await res.text();
return new NextResponse(data);
}
return NextResponse.next()
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "next-edge-proxy-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "15.1.7"
}
}
Loading