Security context
Medium· 5.9GHSA-wr66-vrwm-5g5x CVE-2022-21721CWE-20CWE-400Published Jan 28, 2022

Denial of Service Vulnerability in next.js

Research this vulnerability

Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.

Affected versions

12.0.0 → fixed in 12.0.9

Details

### Impact Vulnerable code could allow a bad actor to trigger a denial of service attack for anyone running a Next.js app at version >= 12.0.0, and using i18n functionality. - **Affected:** All of the following must be true to be affected by this CVE - Next.js versions above v12.0.0 - Using next start or a custom server - Using the built-in i18n support - **Not affected:** - Deployments on Vercel (vercel.com) are not affected along with similar environments where invalid requests are filtered before reaching Next.js. ### Patches A patch has been released, `next@12.0.9`, that mitigates this issue. We recommend all affected users upgrade as soon as possible. ### Workarounds We recommend upgrading whether you can reproduce or not although you can ensure `/${locale}/_next/` is blocked from reaching the Next.js instance until you upgrade. ### For more information If you have any questions or comments about this advisory: * Open an issue in [next](https://github.com/vercel/next.js) * Email us at [security@vercel.com](mailto:security@vercel.com)

The fix

Fix static file check with i18n

JJ Kasper· Jan 18, 2022, 10:15 PM+4568e055bcc94
packages/next/server/base-server.ts+6 3
@@ -958,7 +958,8 @@ export default abstract class Server {
res,
pathname,
{ ..._parsedUrl.query, _nextDataReq: '1' },
- parsedUrl
+ parsedUrl,
+ true
)
return {
finished: true,
@@ -1344,7 +1345,7 @@ export default abstract class Server {
}
try {
- await this.render(req, res, pathname, query, parsedUrl)
+ await this.render(req, res, pathname, query, parsedUrl, true)
return {
finished: true,
@@ -1565,7 +1566,8 @@ export default abstract class Server {
res: BaseNextResponse,
pathname: string,
query: NextParsedUrlQuery = {},
- parsedUrl?: NextUrlWithParsedQuery
+ parsedUrl?: NextUrlWithParsedQuery,
+ internalRender = false
): Promise<void> {
if (!pathname.startsWith('/')) {
console.warn(
@@ -1588,6 +1590,7 @@ export default abstract class Server {
// we don't modify the URL for _next/data request but still
// call render so we special case this to prevent an infinite loop
if (
+ !internalRender &&
!this.minimalMode &&
!query._nextDataReq &&
(req.url?.match(/^\/_next\//) ||
packages/next/server/dev/next-dev-server.ts+1 1
@@ -216,7 +216,7 @@ export default class DevServer extends Server {
const mergedQuery = { ...urlQuery, ...query }
- await this.render(req, res, page, mergedQuery, parsedUrl)
+ await this.render(req, res, page, mergedQuery, parsedUrl, true)
return {
finished: true,
}
packages/next/server/next-server.ts+4 2
@@ -474,14 +474,16 @@ export default class NextNodeServer extends BaseServer {
res: BaseNextResponse | ServerResponse,
pathname: string,
query?: NextParsedUrlQuery,
- parsedUrl?: NextUrlWithParsedQuery
+ parsedUrl?: NextUrlWithParsedQuery,
+ internal = false
): Promise<void> {
return super.render(
this.normalizeReq(req),
this.normalizeRes(res),
pathname,
query,
- parsedUrl
+ parsedUrl,
+ internal
)
}
packages/next/server/router.ts+13 0
@@ -78,6 +78,7 @@ export default class Router {
dynamicRoutes: DynamicRoutes
useFileSystemPublicRoutes: boolean
locales: string[]
+ seenRequests: Set<any>
constructor({
basePath = '',
@@ -123,6 +124,7 @@ export default class Router {
this.dynamicRoutes = dynamicRoutes
this.useFileSystemPublicRoutes = useFileSystemPublicRoutes
this.locales = locales
+ this.seenRequests = new Set()
}
setDynamicRoutes(routes: DynamicRoutes = []) {
@@ -138,6 +140,13 @@ export default class Router {
res: BaseNextResponse,
parsedUrl: NextUrlWithParsedQuery
): Promise<boolean> {
+ if (this.seenRequests.has(req)) {
+ throw new Error(
+ `Invariant: request has already been processed: ${req.url}, this is an internal error please open an issue.`
+ )
+ }
+ this.seenRequests.add(req)
+
// memoize page check calls so we don't duplicate checks for pages
const pageChecks: { [name: string]: Promise<boolean> } = {}
const memoizedPageChecker = async (p: string): Promise<boolean> => {
@@ -360,6 +369,7 @@ export default class Router {
) {
if (requireBasePath) {
// consider this a non-match so the 404 renders
+ this.seenRequests.delete(req)
return false
}
// page checker occurs before rewrites so we need to continue
@@ -374,6 +384,7 @@ export default class Router {
// The response was handled
if (result.finished) {
+ this.seenRequests.delete(req)
return true
}
@@ -397,11 +408,13 @@ export default class Router {
// check filesystem
if (testRoute.check === true) {
if (await applyCheckTrue(parsedUrlUpdated)) {
+ this.seenRequests.delete(req)
return true
}
}
}
}
+ this.seenRequests.delete(req)
return false
}
}
test/integration/i18n-support/test/shared.js+21 0
@@ -1,6 +1,7 @@
/* eslint-env jest */
import url from 'url'
+import glob from 'glob'
import fs from 'fs-extra'
import cheerio from 'cheerio'
import { join } from 'path'
@@ -56,6 +57,26 @@ export function runTests(ctx) {
})
}
+ it('should 404 for locale prefixed static assets correctly', async () => {
+ const assets = glob.sync('**/*.js', {
+ cwd: join(ctx.appDir, '.next/static'),
+ })
+
+ for (const locale of locales) {
+ for (const asset of assets) {
+ // _next/static asset
+ const res = await fetchViaHTTP(
+ ctx.appPort,
+ `${ctx.basePath || ''}/${locale}/_next/static/${asset}`,
+ undefined,
+ { redirect: 'manual' }
+ )
+ expect(res.status).toBe(404)
+ expect(await res.text()).toContain('could not be found')
+ }
+ }
+ })
+
it('should redirect external domain correctly', async () => {
const res = await fetchViaHTTP(
ctx.appPort,

References