Denial of Service condition in Next.js image optimization
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 The image optimization feature of Next.js contained a vulnerability which allowed for a potential Denial of Service (DoS) condition which could lead to excessive CPU consumption. **Not affected:** - The `next.config.js` file is configured with `images.unoptimized` set to `true` or `images.loader` set to a non-default value. - The Next.js application is hosted on Vercel. ### Patches This issue was fully patched in Next.js `14.2.7`. We recommend that users upgrade to at least this version. ### Workarounds Ensure that the `next.config.js` file has either `images.unoptimized`, `images.loader` or `images.loaderFile` assigned. #### Credits Brandon Dahler (brandondahler), AWS Dimitrios Vlastaras
The fix
Reject next image urls in image optimizer (#68628)
packages/next/src/lib/url.ts+8 −0
@@ -11,3 +11,11 @@ export function getPathname(url: string) {export function isFullStringUrl(url: string) {return /https?:\/\//.test(url)}++export function parseUrl(url: string): URL | undefined {+let parsed = undefined+try {+parsed = new URL(url, DUMMY_ORIGIN)+} catch {}+return parsed+}
packages/next/src/server/image-optimizer.ts+8 −3
@@ -28,6 +28,7 @@ import type {import { sendEtagResponse } from './send-payload'import { getContentType, getExtension } from './serve-static'import * as Log from '../build/output/log'+import { parseUrl } from '../lib/url'type XCacheHeader = 'MISS' | 'HIT' | 'STALE'@@ -197,9 +198,13 @@ export class ImageOptimizerCache {}}-if (url.startsWith('/_next/image')) {-return {-errorMessage: '"url" parameter cannot be recursive',+const parsedUrl = parseUrl(url)+if (parsedUrl) {+const decodedPathname = decodeURIComponent(parsedUrl.pathname)+if (/\/_next\/image($|\/)/.test(decodedPathname)) {+return {+errorMessage: '"url" parameter cannot be recursive',+}}}
test/integration/image-optimizer/test/util.ts+41 −5
@@ -8,6 +8,7 @@ import {fetchViaHTTP,File,findPort,+getFetchUrl,killApp,launchApp,nextBuild,@@ -879,11 +880,46 @@ export function runTests(ctx) {)})-it('should fail when url is recursive', async () => {-const query = { url: `/_next/image?url=test.pngw=1&q=1`, w: ctx.w, q: 1 }-const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})-expect(res.status).toBe(400)-expect(await res.text()).toBe(`"url" parameter cannot be recursive`)+describe('recursive url is not allowed', () => {+it('should fail with relative next image url', async () => {+const query = { url: `/_next/image?url=test.pngw=1&q=1`, w: ctx.w, q: 1 }+const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})+expect(res.status).toBe(400)+expect(await res.text()).toBe(`"url" parameter cannot be recursive`)+})++it('should fail with encoded relative image url', async () => {+const query = {+url: '%2F_next%2Fimage%3Furl%3Dtest.pngw%3D1%26q%3D1',+w: ctx.w,+q: 1,+}+const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})+expect(res.status).toBe(400)+expect(await res.text()).toBe(`"url" parameter is invalid`)+})++it('should fail with absolute next image url', async () => {+const fullUrl = getFetchUrl(+ctx.appPort,+'/_next/image?url=test.pngw=1&q=1'+)+const query = { url: fullUrl, w: ctx.w, q: 1 }+const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})+expect(res.status).toBe(400)+expect(await res.text()).toBe(`"url" parameter cannot be recursive`)+})++it('should fail with relative image url with assetPrefix', async () => {+const fullUrl = getFetchUrl(+ctx.appPort,+`/assets/_next/image?url=test.pngw=1&q=1`+)+const query = { url: fullUrl, w: ctx.w, q: 1 }+const res = await fetchViaHTTP(ctx.appPort, '/_next/image', query, {})+expect(res.status).toBe(400)+expect(await res.text()).toBe(`"url" parameter cannot be recursive`)+})})it('should fail when internal url is not an image', async () => {
test/lib/next-test-utils.ts+9 −0
@@ -147,6 +147,15 @@ export function withQuery(return `${pathname}?${querystring}`}+export function getFetchUrl(+appPort: string | number,+pathname: string,+query?: Record<string, any> | string | null | undefined+) {+const url = query ? withQuery(pathname, query) : pathname+return getFullUrl(appPort, url)+}+export function fetchViaHTTP(appPort: string | number,pathname: string,