Denial of Service Vulnerability in next.js
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
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
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 loopif (+!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: DynamicRoutesuseFileSystemPublicRoutes: booleanlocales: string[]+seenRequests: Set<any>constructor({basePath = '',@@ -123,6 +124,7 @@ export default class Router {this.dynamicRoutes = dynamicRoutesthis.useFileSystemPublicRoutes = useFileSystemPublicRoutesthis.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 pagesconst 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 handledif (result.finished) {+this.seenRequests.delete(req)return true}@@ -397,11 +408,13 @@ export default class Router {// check filesystemif (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,