Security context
Medium· 5.9GHSA-g77x-44xx-532m CVE-2024-47831CWE-674Published Oct 14, 2024

Denial of Service condition in Next.js image optimization

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

10.0.0 → fixed in 14.2.7

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)

Jiachi Liu· Aug 8, 2024, 08:35 AM+668d11cbc9ff0
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,

References