Skip to content

Commit 188aa80

Browse files
committed
Enhance Next.js API endpoint handling for compatibility with both Pages and App Router structures.
1 parent 02d2369 commit 188aa80

File tree

4 files changed

+80
-6
lines changed

4 files changed

+80
-6
lines changed

Diff for: javascript/ql/lib/semmle/javascript/frameworks/Next.qll

+56-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,56 @@ 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 | mod.getFile().getParentContainer() = apiFolder() |
285+
this = mod.getAnExportedValue(any(Http::RequestMethodName m)).getAFunctionValue() and
286+
(
287+
this.getParameter(0).hasUnderlyingType("next/server", "NextRequest")
288+
or
289+
this.getParameter(0).hasUnderlyingType("Request")
290+
)
291+
)
292+
}
293+
294+
/**
295+
* Gets the request parameter, which is either a `NextRequest` object (from `next/server`) or a standard web `Request` object.
296+
*/
297+
DataFlow::SourceNode getRequest() { result = this.getParameter(0) }
298+
}
299+
300+
/**
301+
* A source of user-controlled data from a `NextRequest` object (from `next/server`) or a standard web `Request` object
302+
* in a Next.js App Router route handler.
303+
*/
304+
class NextAppRequestSource extends Http::RequestInputAccess {
305+
NextAppRouteHandler handler;
306+
string kind;
307+
308+
NextAppRequestSource() {
309+
(
310+
this =
311+
handler.getRequest().getAMethodCall(["json", "formData", "blob", "arrayBuffer", "text"])
312+
or
313+
this = handler.getRequest().getAPropertyRead("body")
314+
) and
315+
kind = "body"
316+
or
317+
this = handler.getRequest().getAPropertyRead(["url", "nextUrl"]) and kind = "url"
318+
or
319+
this = handler.getRequest().getAPropertyRead("headers") and kind = "headers"
320+
}
321+
322+
override string getKind() { result = kind }
323+
324+
override Http::RouteHandler getRouteHandler() { result = handler }
325+
326+
override string getSourceType() { result = "Next.js App Router request" }
327+
}
274328
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export async function POST(req: Request) {
2-
const { url } = await req.json(); // $ MISSING: Source[js/request-forgery]
3-
const res = await fetch(url); // $ MISSING: Alert[js/request-forgery] Sink[js/request-forgery]
2+
const { url } = await req.json(); // $ Source[js/request-forgery]
3+
const res = await fetch(url); // $ Alert[js/request-forgery] Sink[js/request-forgery]
44
return new Response(res.body, { headers: res.headers });
55
}
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { NextRequest, NextResponse } from 'next/server';
22

33
export async function POST(req: NextRequest) {
4-
const { url } = await req.json(); // $ MISSING: Source[js/request-forgery]
5-
const res = await fetch(url); // $ MISSING: Alert[js/request-forgery] Sink[js/request-forgery]
4+
const { url } = await req.json(); // $ Source[js/request-forgery]
5+
const res = await fetch(url); // $ Alert[js/request-forgery] Sink[js/request-forgery]
66
const data = await res.text();
77
return new NextResponse(data, { headers: res.headers });
88
}

Diff for: javascript/ql/test/query-tests/Security/CWE-918/RequestForgery.expected

+20
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#select
22
| apollo.serverSide.ts:8:39:8:64 | get(fil ... => {}) | apollo.serverSide.ts:7:36:7:44 | { files } | apollo.serverSide.ts:8:43:8:50 | file.url | The $@ of this request depends on a $@. | apollo.serverSide.ts:8:43:8:50 | file.url | URL | apollo.serverSide.ts:7:36:7:44 | { files } | user-provided value |
33
| axiosInterceptors.serverSide.js:11:26:11:40 | userProvidedUrl | axiosInterceptors.serverSide.js:19:21:19:28 | req.body | axiosInterceptors.serverSide.js:11:26:11:40 | userProvidedUrl | The $@ of this request depends on a $@. | axiosInterceptors.serverSide.js:11:26:11:40 | userProvidedUrl | endpoint | axiosInterceptors.serverSide.js:19:21:19:28 | req.body | user-provided value |
4+
| request/app/api/proxy/route2.serverSide.ts:5:21:5:30 | fetch(url) | request/app/api/proxy/route2.serverSide.ts:4:25:4:34 | req.json() | request/app/api/proxy/route2.serverSide.ts:5:27:5:29 | url | The $@ of this request depends on a $@. | request/app/api/proxy/route2.serverSide.ts:5:27:5:29 | url | URL | request/app/api/proxy/route2.serverSide.ts:4:25:4:34 | req.json() | user-provided value |
5+
| request/app/api/proxy/route.serverSide.ts:3:21:3:30 | fetch(url) | request/app/api/proxy/route.serverSide.ts:2:25:2:34 | req.json() | request/app/api/proxy/route.serverSide.ts:3:27:3:29 | url | The $@ of this request depends on a $@. | request/app/api/proxy/route.serverSide.ts:3:27:3:29 | url | URL | request/app/api/proxy/route.serverSide.ts:2:25:2:34 | req.json() | user-provided value |
46
| serverSide.js:18:5:18:20 | request(tainted) | serverSide.js:14:29:14:35 | req.url | serverSide.js:18:13:18:19 | tainted | The $@ of this request depends on a $@. | serverSide.js:18:13:18:19 | tainted | URL | serverSide.js:14:29:14:35 | req.url | user-provided value |
57
| serverSide.js:20:5:20:24 | request.get(tainted) | serverSide.js:14:29:14:35 | req.url | serverSide.js:20:17:20:23 | tainted | The $@ of this request depends on a $@. | serverSide.js:20:17:20:23 | tainted | URL | serverSide.js:14:29:14:35 | req.url | user-provided value |
68
| serverSide.js:24:5:24:20 | request(options) | serverSide.js:14:29:14:35 | req.url | serverSide.js:23:19:23:25 | tainted | The $@ of this request depends on a $@. | serverSide.js:23:19:23:25 | tainted | URL | serverSide.js:14:29:14:35 | req.url | user-provided value |
@@ -36,6 +38,14 @@ edges
3638
| axiosInterceptors.serverSide.js:19:21:19:28 | req.body | axiosInterceptors.serverSide.js:19:11:19:17 | { url } | provenance | |
3739
| axiosInterceptors.serverSide.js:20:5:20:25 | userProvidedUrl | axiosInterceptors.serverSide.js:11:26:11:40 | userProvidedUrl | provenance | |
3840
| axiosInterceptors.serverSide.js:20:23:20:25 | url | axiosInterceptors.serverSide.js:20:5:20:25 | userProvidedUrl | provenance | |
41+
| request/app/api/proxy/route2.serverSide.ts:4:9:4:15 | { url } | request/app/api/proxy/route2.serverSide.ts:4:9:4:34 | url | provenance | |
42+
| request/app/api/proxy/route2.serverSide.ts:4:9:4:34 | url | request/app/api/proxy/route2.serverSide.ts:5:27:5:29 | url | provenance | |
43+
| request/app/api/proxy/route2.serverSide.ts:4:19:4:34 | await req.json() | request/app/api/proxy/route2.serverSide.ts:4:9:4:15 | { url } | provenance | |
44+
| request/app/api/proxy/route2.serverSide.ts:4:25:4:34 | req.json() | request/app/api/proxy/route2.serverSide.ts:4:19:4:34 | await req.json() | provenance | |
45+
| request/app/api/proxy/route.serverSide.ts:2:9:2:15 | { url } | request/app/api/proxy/route.serverSide.ts:2:9:2:34 | url | provenance | |
46+
| request/app/api/proxy/route.serverSide.ts:2:9:2:34 | url | request/app/api/proxy/route.serverSide.ts:3:27:3:29 | url | provenance | |
47+
| request/app/api/proxy/route.serverSide.ts:2:19:2:34 | await req.json() | request/app/api/proxy/route.serverSide.ts:2:9:2:15 | { url } | provenance | |
48+
| request/app/api/proxy/route.serverSide.ts:2:25:2:34 | req.json() | request/app/api/proxy/route.serverSide.ts:2:19:2:34 | await req.json() | provenance | |
3949
| serverSide.js:14:9:14:52 | tainted | serverSide.js:18:13:18:19 | tainted | provenance | |
4050
| serverSide.js:14:9:14:52 | tainted | serverSide.js:20:17:20:23 | tainted | provenance | |
4151
| serverSide.js:14:9:14:52 | tainted | serverSide.js:23:19:23:25 | tainted | provenance | |
@@ -97,6 +107,16 @@ nodes
97107
| axiosInterceptors.serverSide.js:19:21:19:28 | req.body | semmle.label | req.body |
98108
| axiosInterceptors.serverSide.js:20:5:20:25 | userProvidedUrl | semmle.label | userProvidedUrl |
99109
| axiosInterceptors.serverSide.js:20:23:20:25 | url | semmle.label | url |
110+
| request/app/api/proxy/route2.serverSide.ts:4:9:4:15 | { url } | semmle.label | { url } |
111+
| request/app/api/proxy/route2.serverSide.ts:4:9:4:34 | url | semmle.label | url |
112+
| request/app/api/proxy/route2.serverSide.ts:4:19:4:34 | await req.json() | semmle.label | await req.json() |
113+
| request/app/api/proxy/route2.serverSide.ts:4:25:4:34 | req.json() | semmle.label | req.json() |
114+
| request/app/api/proxy/route2.serverSide.ts:5:27:5:29 | url | semmle.label | url |
115+
| request/app/api/proxy/route.serverSide.ts:2:9:2:15 | { url } | semmle.label | { url } |
116+
| request/app/api/proxy/route.serverSide.ts:2:9:2:34 | url | semmle.label | url |
117+
| request/app/api/proxy/route.serverSide.ts:2:19:2:34 | await req.json() | semmle.label | await req.json() |
118+
| request/app/api/proxy/route.serverSide.ts:2:25:2:34 | req.json() | semmle.label | req.json() |
119+
| request/app/api/proxy/route.serverSide.ts:3:27:3:29 | url | semmle.label | url |
100120
| serverSide.js:14:9:14:52 | tainted | semmle.label | tainted |
101121
| serverSide.js:14:19:14:42 | url.par ... , true) | semmle.label | url.par ... , true) |
102122
| serverSide.js:14:29:14:35 | req.url | semmle.label | req.url |

0 commit comments

Comments
 (0)