SSRF

HIGH
vercel/next.js
Commit: 5452439f3db2
Affected: < 16.2.2
2026-05-04 18:10 UTC

Description

The commit implements a real SSRF mitigation in the Next.js image optimizer by blocking fetchExternalImage requests that resolve to private IPs unless explicitly allowed via a dangerous flag (images.dangerouslyAllowLocalIP). It also refines the error message presented when a private IP resolution is detected and adds unit tests to guard this behavior. This is more than a documentation or test-only change; it changes runtime behavior to prevent SSRF via image URL fetching.

Proof of Concept

PoC (pre-fix exploitation path): - Scenario: An attacker can induce the Next.js server to fetch an internal resource by supplying an image URL that resolves to a private IP. - Environment: A Next.js app using the image optimizer with an endpoint like /_next/image that fetches external images. An internal service is reachable at a private IP (e.g., 10.0.0.42:8080). - Attacker-controlled request (example): curl -s 'https://your-app.example.com/_next/image?url=http://10.0.0.42:8080/secret.png&w=800&q=75' >/dev/null - What happens before the fix: The server's image-optimizer fetchExternalImage would reach the internal private IP 10.0.0.42 and fetch the resource, enabling an SSRF path to internal services. This could allow probing internal networks or accessing internal endpoints via the image fetch step. - Expected outcome: The internal resource is requested by the Next.js server, creating potential exposure or impact depending on the internal service. Post-fix behavior (as implemented by this commit): - The same request would be rejected with a 400 error, and the error message (for clarity) indicates the URL parameter is not allowed due to hostname resolving to a private IP, unless images.dangerouslyAllowLocalIP is explicitly enabled. - Example response after fix: 400 Bad Request with message like "\"url\" parameter is not allowed" and guidance about private IP rejection. Optional programmatic test (conceptual): - Without dangerous flag, attempting to fetch a URL resolving to a private IP should throw an ImageError with statusCode 400 and message "\"url\" parameter is not allowed". - With images.dangerouslyAllowLocalIP = true, the fetch could proceed (understanding the SSRF risk). Two concrete curl-based steps you can try in a controlled environment: 1) Verify rejection for private IP: curl -s https://your-app.example.com/_next/image?url=http://192.168.1.50/private.png&w=800&q=75 Expected: 400 with message indicating private IP rejection. 2) Verify permission path (only in a controlled test with dangerous flag set): curl -s 'https://your-app.example.com/_next/image?url=http://192.168.1.50/private.png&w=800&q=75&dangerouslyAllowLocalIP=true' Expected: The request proceeds to fetch and return a transformed image (or success), confirming the hardening would require explicit opt-in.

Commit Details

Author: Rishi

Date: 2026-05-04 17:15 UTC

Message:

fix(next/image): Improve error message for private IP (SSRF) rejections (#91686) Co-authored-by: Steven <steven@ceriously.com>

Triage Assessment

Vulnerability Type: SSRF

Confidence: HIGH

Reasoning:

The commit strengthens SSRF protection for external image fetching by distinguishing private IP resolutions and preventing fetch unless explicitly allowed via a dangerous flag. It updates the error messaging to be clearer about private IP rejections and adds tests for the SSRF guard, indicating a security-focused fix.

Verification Assessment

Vulnerability Type: SSRF

Confidence: HIGH

Affected Versions: < 16.2.2

Code Diff

diff --git a/packages/next/src/server/image-optimizer.ts b/packages/next/src/server/image-optimizer.ts index bb910950a32b..237253dbc1d3 100644 --- a/packages/next/src/server/image-optimizer.ts +++ b/packages/next/src/server/image-optimizer.ts @@ -882,8 +882,9 @@ export async function fetchExternalImage( Log.error( 'upstream image', href, - 'resolved to private ip', - JSON.stringify(privateIps) + 'hostname resolved to private IP', + JSON.stringify(privateIps), + 'If this is expected and you understand SSRF risk, use images.dangerouslyAllowLocalIP = true to continue.' ) throw new ImageError(400, '"url" parameter is not allowed') } diff --git a/test/unit/image-optimizer/fetch-external-image.test.ts b/test/unit/image-optimizer/fetch-external-image.test.ts index d20fc916729a..46220b538c2c 100644 --- a/test/unit/image-optimizer/fetch-external-image.test.ts +++ b/test/unit/image-optimizer/fetch-external-image.test.ts @@ -5,6 +5,54 @@ import { } from 'next/dist/server/image-optimizer' describe('fetchExternalImage', () => { + describe('private IP / SSRF guard', () => { + it('should reject a literal private IP hostname with a generic error message', async () => { + const fetchMock = jest.fn() + global.fetch = fetchMock + + const error = await fetchExternalImage( + 'http://192.168.0.1/private.jpg', + false, + 50_000_000 + ).catch((e) => e) + + expect(error).toBeInstanceOf(ImageError) + expect((error as ImageError).statusCode).toBe(400) + expect((error as ImageError).message).toBe( + '"url" parameter is not allowed' + ) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('should allow a literal private IP when dangerouslyAllowLocalIP is true', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([1, 2, 3])) + controller.close() + }, + }), + headers: { + get: jest.fn((header: string) => { + if (header === 'Content-Type') return 'image/jpeg' + return null + }), + }, + }) + + const result = await fetchExternalImage( + 'http://192.168.0.1/private.jpg', + true, + 50_000_000 + ) + + expect(result.buffer).toBeInstanceOf(Buffer) + expect(global.fetch).toHaveBeenCalled() + }) + }) + describe('response size limit', () => { it('should throw error when response has no body', async () => { global.fetch = jest.fn().mockResolvedValue({
← Back to Alerts View on GitHub →