Skip to content

Commit 8631371

Browse files
authored
Merge pull request #19184 from Napalys/js/request_handlers
JS: Support for `Request` and `NextRequest`
2 parents 8552710 + 11abbf8 commit 8631371

File tree

14 files changed

+254
-31
lines changed

14 files changed

+254
-31
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* 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`.
5+
* 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`.

javascript/ql/lib/semmle/javascript/frameworks/Next.qll

+64-2
Original file line numberDiff line numberDiff line change
@@ -213,10 +213,12 @@ module NextJS {
213213
/**
214214
* Gets a folder that contains API endpoints for a Next.js application.
215215
* These API endpoints act as Express-like route-handlers.
216+
* It matches both the Pages Router (`pages/api/`) Next.js 12 or earlier and
217+
* the App Router (`app/api/`) Next.js 13+ structures.
216218
*/
217219
Folder apiFolder() {
218-
result = getANextPackage().getFile().getParentContainer().getFolder("pages").getFolder("api")
219-
or
220+
result =
221+
getANextPackage().getFile().getParentContainer().getFolder(["pages", "app"]).getFolder("api") or
220222
result = apiFolder().getAFolder()
221223
}
222224

@@ -271,4 +273,64 @@ module NextJS {
271273
override string getCredentialsKind() { result = "jwt key" }
272274
}
273275
}
276+
277+
/**
278+
* A route handler for Next.js 13+ App Router API endpoints, which are defined by exporting
279+
* HTTP method functions (like `GET`, `POST`, `PUT`, `DELETE`) from route.js files inside
280+
* the `app/api/` directory.
281+
*/
282+
class NextAppRouteHandler extends DataFlow::FunctionNode, Http::Servers::StandardRouteHandler {
283+
NextAppRouteHandler() {
284+
exists(Module mod |
285+
mod.getFile().getParentContainer() = apiFolder() or
286+
mod.getFile().getStem() = "middleware"
287+
|
288+
this =
289+
mod.getAnExportedValue([any(Http::RequestMethodName m), "middleware"]).getAFunctionValue()
290+
)
291+
}
292+
293+
/**
294+
* Gets the request parameter, which is either a `NextRequest` object (from `next/server`) or a standard web `Request` object.
295+
*/
296+
DataFlow::SourceNode getRequest() { result = this.getParameter(0) }
297+
}
298+
299+
/**
300+
* A source of user-controlled data from a `NextRequest` object (from `next/server`) or a standard web `Request` object
301+
* in a Next.js App Router route handler.
302+
*/
303+
class NextAppRequestSource extends Http::RequestInputAccess {
304+
NextAppRouteHandler handler;
305+
string kind;
306+
307+
NextAppRequestSource() {
308+
(
309+
this =
310+
handler.getRequest().getAMethodCall(["json", "formData", "blob", "arrayBuffer", "text"])
311+
or
312+
this = handler.getRequest().getAPropertyRead("body")
313+
) and
314+
kind = "body"
315+
or
316+
this = handler.getRequest().getAPropertyRead(["url", "nextUrl"]) and
317+
kind = "url"
318+
or
319+
this =
320+
handler
321+
.getRequest()
322+
.getAPropertyRead("nextUrl")
323+
.getAPropertyRead("searchParams")
324+
.getAMemberCall("get") and
325+
kind = "parameter"
326+
or
327+
this = handler.getRequest().getAPropertyRead("headers") and kind = "headers"
328+
}
329+
330+
override string getKind() { result = kind }
331+
332+
override Http::RouteHandler getRouteHandler() { result = handler }
333+
334+
override string getSourceType() { result = "Next.js App Router request" }
335+
}
274336
}

javascript/ql/lib/semmle/javascript/frameworks/WebResponse.qll

+5-2
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@ private class HeadersEntryPoint extends API::EntryPoint {
1919
}
2020

2121
/**
22-
* A call to the `Response` constructor.
22+
* A call to the `Response` and `NextResponse` constructor.
2323
*/
2424
private class ResponseCall extends API::InvokeNode {
25-
ResponseCall() { this = any(ResponseEntryPoint e).getANode().getAnInstantiation() }
25+
ResponseCall() {
26+
this = any(ResponseEntryPoint e).getANode().getAnInstantiation() or
27+
this = API::moduleImport("next/server").getMember("NextResponse").getAnInstantiation()
28+
}
2629
}
2730

2831
/**

javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXss.expected

+34
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
| 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 |
2828
| 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 |
2929
| 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 |
30+
| 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 |
31+
| 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 |
32+
| 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 |
33+
| 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 |
34+
| 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 |
35+
| 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 |
36+
| 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 |
37+
| 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 |
3038
| 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 |
3139
| 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 |
3240
| 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 |
@@ -128,6 +136,18 @@ edges
128136
| ReflectedXssGood3.js:135:15:135:27 | req.params.id | ReflectedXssGood3.js:135:9:135:27 | url | provenance | |
129137
| ReflectedXssGood3.js:139:24:139:26 | url | ReflectedXssGood3.js:68:22:68:26 | value | provenance | |
130138
| ReflectedXssGood3.js:139:24:139:26 | url | ReflectedXssGood3.js:139:12:139:27 | escapeHtml3(url) | provenance | |
139+
| app/api/route.ts:2:11:2:33 | body | app/api/route.ts:5:18:5:21 | body | provenance | |
140+
| app/api/route.ts:2:11:2:33 | body | app/api/route.ts:13:18:13:21 | body | provenance | |
141+
| app/api/route.ts:2:11:2:33 | body | app/api/route.ts:25:18:25:21 | body | provenance | |
142+
| app/api/route.ts:2:11:2:33 | body | app/api/route.ts:29:25:29:28 | body | provenance | |
143+
| app/api/route.ts:2:18:2:33 | await req.json() | app/api/route.ts:2:11:2:33 | body | provenance | |
144+
| app/api/route.ts:2:24:2:33 | req.json() | app/api/route.ts:2:18:2:33 | await req.json() | provenance | |
145+
| app/api/routeNextRequest.ts:4:9:4:31 | body | app/api/routeNextRequest.ts:7:20:7:23 | body | provenance | |
146+
| app/api/routeNextRequest.ts:4:9:4:31 | body | app/api/routeNextRequest.ts:15:20:15:23 | body | provenance | |
147+
| app/api/routeNextRequest.ts:4:9:4:31 | body | app/api/routeNextRequest.ts:27:20:27:23 | body | provenance | |
148+
| app/api/routeNextRequest.ts:4:9:4:31 | body | app/api/routeNextRequest.ts:31:27:31:30 | body | provenance | |
149+
| app/api/routeNextRequest.ts:4:16:4:31 | await req.json() | app/api/routeNextRequest.ts:4:9:4:31 | body | provenance | |
150+
| app/api/routeNextRequest.ts:4:22:4:31 | req.json() | app/api/routeNextRequest.ts:4:16:4:31 | await req.json() | provenance | |
131151
| etherpad.js:9:5:9:53 | response | etherpad.js:11:12:11:19 | response | provenance | |
132152
| etherpad.js:9:16:9:30 | req.query.jsonp | etherpad.js:9:5:9:53 | response | provenance | |
133153
| formatting.js:4:9:4:29 | evil | formatting.js:6:43:6:46 | evil | provenance | |
@@ -309,6 +329,20 @@ nodes
309329
| ReflectedXssGood3.js:135:15:135:27 | req.params.id | semmle.label | req.params.id |
310330
| ReflectedXssGood3.js:139:12:139:27 | escapeHtml3(url) | semmle.label | escapeHtml3(url) |
311331
| ReflectedXssGood3.js:139:24:139:26 | url | semmle.label | url |
332+
| app/api/route.ts:2:11:2:33 | body | semmle.label | body |
333+
| app/api/route.ts:2:18:2:33 | await req.json() | semmle.label | await req.json() |
334+
| app/api/route.ts:2:24:2:33 | req.json() | semmle.label | req.json() |
335+
| app/api/route.ts:5:18:5:21 | body | semmle.label | body |
336+
| app/api/route.ts:13:18:13:21 | body | semmle.label | body |
337+
| app/api/route.ts:25:18:25:21 | body | semmle.label | body |
338+
| app/api/route.ts:29:25:29:28 | body | semmle.label | body |
339+
| app/api/routeNextRequest.ts:4:9:4:31 | body | semmle.label | body |
340+
| app/api/routeNextRequest.ts:4:16:4:31 | await req.json() | semmle.label | await req.json() |
341+
| app/api/routeNextRequest.ts:4:22:4:31 | req.json() | semmle.label | req.json() |
342+
| app/api/routeNextRequest.ts:7:20:7:23 | body | semmle.label | body |
343+
| app/api/routeNextRequest.ts:15:20:15:23 | body | semmle.label | body |
344+
| app/api/routeNextRequest.ts:27:20:27:23 | body | semmle.label | body |
345+
| app/api/routeNextRequest.ts:31:27:31:30 | body | semmle.label | body |
312346
| etherpad.js:9:5:9:53 | response | semmle.label | response |
313347
| etherpad.js:9:16:9:30 | req.query.jsonp | semmle.label | req.query.jsonp |
314348
| etherpad.js:11:12:11:19 | response | semmle.label | response |

javascript/ql/test/query-tests/Security/CWE-079/ReflectedXss/ReflectedXssWithCustomSanitizer.expected

+8
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@
2626
| 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 |
2727
| 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 |
2828
| 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 |
29+
| 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 |
30+
| 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 |
31+
| 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 |
32+
| 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 |
33+
| 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 |
34+
| 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 |
35+
| 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 |
36+
| 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 |
2937
| 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 |
3038
| 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 |
3139
| 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 |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export async function POST(req: Request) {
2+
const body = await req.json(); // $ Source
3+
4+
new Response(body, {headers: { 'Content-Type': 'application/json' }});
5+
new Response(body, {headers: { 'Content-Type': 'text/html' }}); // $ Alert
6+
7+
const headers2 = new Headers(req.headers);
8+
headers2.append('Content-Type', 'application/json');
9+
new Response(body, { headers: headers2 });
10+
11+
const headers3 = new Headers(req.headers);
12+
headers3.append('Content-Type', 'text/html');
13+
new Response(body, { headers: headers3 }); // $ Alert
14+
15+
const headers4 = new Headers({
16+
...Object.fromEntries(req.headers),
17+
'Content-Type': 'application/json'
18+
});
19+
new Response(body, { headers: headers4 });
20+
21+
const headers5 = new Headers({
22+
...Object.fromEntries(req.headers),
23+
'Content-Type': 'text/html'
24+
});
25+
new Response(body, { headers: headers5 }); // $ Alert
26+
27+
const headers = new Headers(req.headers);
28+
headers.set('Content-Type', 'text/html');
29+
return new Response(body, { headers }); // $ Alert
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
export async function POST(req: NextRequest) {
4+
const body = await req.json(); // $ Source
5+
6+
new NextResponse(body, {headers: { 'Content-Type': 'application/json' }});
7+
new NextResponse(body, {headers: { 'Content-Type': 'text/html' }}); // $ Alert
8+
9+
const headers2 = new Headers(req.headers);
10+
headers2.append('Content-Type', 'application/json');
11+
new NextResponse(body, { headers: headers2 });
12+
13+
const headers3 = new Headers(req.headers);
14+
headers3.append('Content-Type', 'text/html');
15+
new NextResponse(body, { headers: headers3 }); // $ Alert
16+
17+
const headers4 = new Headers({
18+
...Object.fromEntries(req.headers),
19+
'Content-Type': 'application/json'
20+
});
21+
new NextResponse(body, { headers: headers4 });
22+
23+
const headers5 = new Headers({
24+
...Object.fromEntries(req.headers),
25+
'Content-Type': 'text/html'
26+
});
27+
new NextResponse(body, { headers: headers5 }); // $ Alert
28+
29+
const headers = new Headers(req.headers);
30+
headers.set('Content-Type', 'text/html');
31+
return new NextResponse(body, { headers }); // $ Alert
32+
}

javascript/ql/test/query-tests/Security/CWE-918/Consistency.expected

-2
This file was deleted.

javascript/ql/test/query-tests/Security/CWE-918/Consistency.ql

-25
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export async function POST(req: Request) {
2+
const { url } = await req.json(); // $ Source[js/request-forgery]
3+
const res = await fetch(url); // $ Alert[js/request-forgery] Sink[js/request-forgery]
4+
return new Response(res.body, { headers: res.headers });
5+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
export async function POST(req: NextRequest) {
4+
const { url } = await req.json(); // $ Source[js/request-forgery]
5+
const res = await fetch(url); // $ Alert[js/request-forgery] Sink[js/request-forgery]
6+
const data = await res.text();
7+
return new NextResponse(data, { headers: res.headers });
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
export async function middleware(req: NextRequest) {
4+
const target = req.nextUrl // $ Source[js/request-forgery]
5+
const target2 = target.searchParams.get('target'); // $ Source[js/request-forgery]
6+
if (target) {
7+
const res = await fetch(target) // $ Alert[js/request-forgery] Sink[js/request-forgery]
8+
const data = await res.text()
9+
return new NextResponse(data)
10+
}
11+
if (target2) {
12+
const res = await fetch(target2); // $ Alert[js/request-forgery] Sink[js/request-forgery]
13+
const data = await res.text();
14+
return new NextResponse(data);
15+
}
16+
return NextResponse.next()
17+
}
18+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "next-edge-proxy-app",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev",
7+
"build": "next build",
8+
"start": "next start"
9+
},
10+
"dependencies": {
11+
"next": "15.1.7"
12+
}
13+
}

0 commit comments

Comments
 (0)