VYPR
High severityNVD Advisory· Published Mar 4, 2026· Updated Mar 4, 2026

SSRF vulnerability in opennextjs-cloudflare via /cdn-cgi/ path normalization bypass

CVE-2026-3125

Description

A Server-Side Request Forgery (SSRF) vulnerability was identified in the @opennextjs/cloudflare package, resulting from a path normalization bypass in the /cdn-cgi/image/ handler.The @opennextjs/cloudflare worker template includes a /cdn-cgi/image/ handler intended for development use only. In production, Cloudflare's edge intercepts /cdn-cgi/image/ requests before they reach the Worker. However, by substituting a backslash for a forward slash (/cdn-cgi\image/ instead of /cdn-cgi/image/), an attacker can bypass edge interception and have the request reach the Worker directly. The JavaScript URL class then normalizes the backslash to a forward slash, causing the request to match the handler and trigger an unvalidated fetch of arbitrary remote URLs.

For example:

https://victim-site.com/cdn-cgi\image/aaaa/https://attacker.com

In this example, attacker-controlled content from attacker.com is served through the victim site's domain (victim-site.com), violating the same-origin policy and potentially misleading users or other services.

Note: This bypass only works via HTTP clients that preserve backslashes in paths (e.g., curl --path-as-is). Browsers normalize backslashes to forward slashes before sending requests.

Additionally, Cloudflare Workers with Assets and Cloudflare Pages suffer from a similar vulnerability. Assets stored under /cdn-cgi/ paths are not publicly accessible under normal conditions. However, using the same backslash bypass (/cdn-cgi\... instead of /cdn-cgi/...), these assets become publicly accessible. This could be used to retrieve private data. For example, Open Next projects store incremental cache data under /cdn-cgi/_next_cache, which could be exposed via this bypass.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

An SSRF in @opennextjs/cloudflare lets attackers bypass Cloudflare edge interception using a backslash in /cdn-cgi/ paths to fetch arbitrary remote URLs or expose private assets.

A Server-Side Request Forgery (SSRF) vulnerability exists in the @opennextjs/cloudflare package due to a path normalization bypass in the /cdn-cgi/image/ handler [1]. The Worker template includes a development-only handler for /cdn-cgi/image/; in production, Cloudflare's edge normally intercepts requests to that path [1]. However, substituting a backslash for a forward slash (e.g., /cdn-cgi\image/) causes the request to bypass edge interception and reach the Worker directly [1]. The JavaScript URL class then normalizes the backslash to a forward slash, matching the handler and triggering an unvalidated fetch of arbitrary remote URLs [1].

An attacker can exploit this by crafting requests like https://victim-site.com/cdn-cgi\image/aaaa/https://attacker.com using HTTP clients that preserve backslashes in paths, such as curl --path-as-is [1]. The request reaches the Worker, which fetches attacker-controlled content from https://attacker.com and serves it under the victim site's domain, violating the same-origin policy [1]. The same backslash bypass also exposes protected assets stored under /cdn-cgi/ paths, such as incremental cache data in /cdn-cgi/_next_cache [1].

The primary impact is SSRF allowing arbitrary remote content to be served from the victim's domain, enabling same-origin policy bypass and potential phishing or chain attacks [1][3]. Additionally, private assets stored under /cdn-cgi/ become publicly accessible, risking leakage of sensitive data [1][3]. The CVE is related to an earlier SSRF (CVE-2025-6087) found in the /_next/image endpoint, which was addressed separately [4].

Mitigation involves upgrading to a patched version of the @opennextjs/cloudflare package (patched in commit f5bd138f) [2]. The fix ensures that the /cdn-cgi/image/ handler properly validates requests and does not process backslash-normalized paths [2]. Developers are also advised to use the remotePatterns filter in Next.js configuration to restrict allowed external URLs [4].

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@opennextjs/cloudflarenpm
< 1.17.11.17.1

Affected products

3

Patches

1
f5bd138fd3c7

make dev /cdn-cgi/image behaves like prod for consistency (#1147)

https://github.com/opennextjs/opennextjs-cloudflareVictor BerchetFeb 24, 2026via ghsa
3 files changed · +305 24
  • packages/cloudflare/src/cli/templates/images.spec.ts+153 1 modified
    @@ -2,7 +2,12 @@ import pm from "picomatch";
     import { describe, expect, it } from "vitest";
     
     import type { LocalPattern } from "./images.js";
    -import { matchLocalPattern, matchRemotePattern as mRP } from "./images.js";
    +import {
    +	detectImageContentType,
    +	matchLocalPattern,
    +	matchRemotePattern as mRP,
    +	parseCdnCgiImageRequest,
    +} from "./images.js";
     
     /**
      * See https://github.com/vercel/next.js/blob/64702a9/test/unit/image-optimizer/match-remote-pattern.test.ts
    @@ -426,3 +431,150 @@ describe("matchLocalPattern", () => {
     		expect(mLP(p, "/path/to/file.txt?q=1&a=two")).toBe(true);
     	});
     });
    +
    +describe("parseCdnCgiImageRequest", () => {
    +	it("should parse a valid local image request", () => {
    +		const result = parseCdnCgiImageRequest(
    +			"/cdn-cgi/image/width=640,quality=75,format=auto/_next/static/media/photo.png"
    +		);
    +		expect(result).toEqual({ ok: true, url: "/_next/static/media/photo.png", static: false });
    +	});
    +
    +	it("should parse a valid remote image request", () => {
    +		const result = parseCdnCgiImageRequest(
    +			"/cdn-cgi/image/width=1080,quality=75,format=auto/https://example.com/photo.jpg"
    +		);
    +		expect(result).toEqual({ ok: true, url: "https://example.com/photo.jpg", static: false });
    +	});
    +
    +	it("should reject when pathname does not match /cdn-cgi/image/ format", () => {
    +		const result = parseCdnCgiImageRequest("/cdn-cgi/image/");
    +		expect(result).toEqual({ ok: false, message: "Invalid /cdn-cgi/image/ URL format" });
    +	});
    +
    +	it("should reject when options segment has no trailing image URL", () => {
    +		const result = parseCdnCgiImageRequest("/cdn-cgi/image/width=640");
    +		expect(result).toEqual({ ok: false, message: "Invalid /cdn-cgi/image/ URL format" });
    +	});
    +
    +	it("should reject protocol-relative URLs", () => {
    +		const result = parseCdnCgiImageRequest(
    +			"/cdn-cgi/image/width=640,quality=75,format=auto//evil.com/photo.jpg"
    +		);
    +		expect(result).toEqual({
    +			ok: false,
    +			message: '"url" parameter cannot be a protocol-relative URL (//)',
    +		});
    +	});
    +
    +	it("should add leading slash to relative image URLs", () => {
    +		const result = parseCdnCgiImageRequest(
    +			"/cdn-cgi/image/width=640,quality=75,format=auto/path/to/image.png"
    +		);
    +		expect(result).toMatchObject({ ok: true, url: "/path/to/image.png" });
    +	});
    +});
    +
    +describe("detectImageContentType", () => {
    +	it("should detect JPEG", () => {
    +		const buffer = new Uint8Array(32);
    +		buffer[0] = 0xff;
    +		buffer[1] = 0xd8;
    +		buffer[2] = 0xff;
    +		expect(detectImageContentType(buffer)).toBe("image/jpeg");
    +	});
    +
    +	it("should detect PNG", () => {
    +		const buffer = new Uint8Array(32);
    +		const pngHeader = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
    +		pngHeader.forEach((b, i) => (buffer[i] = b));
    +		expect(detectImageContentType(buffer)).toBe("image/png");
    +	});
    +
    +	it("should detect GIF", () => {
    +		const buffer = new Uint8Array(32);
    +		const gifHeader = [0x47, 0x49, 0x46, 0x38];
    +		gifHeader.forEach((b, i) => (buffer[i] = b));
    +		expect(detectImageContentType(buffer)).toBe("image/gif");
    +	});
    +
    +	it("should detect WebP", () => {
    +		const buffer = new Uint8Array(32);
    +		// RIFF....WEBP
    +		const webpHeader = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50];
    +		webpHeader.forEach((b, i) => (buffer[i] = b));
    +		expect(detectImageContentType(buffer)).toBe("image/webp");
    +	});
    +
    +	it("should detect SVG (<?xml prefix)", () => {
    +		const buffer = new Uint8Array(32);
    +		const svgHeader = [0x3c, 0x3f, 0x78, 0x6d, 0x6c];
    +		svgHeader.forEach((b, i) => (buffer[i] = b));
    +		expect(detectImageContentType(buffer)).toBe("image/svg+xml");
    +	});
    +
    +	it("should detect SVG (<svg prefix)", () => {
    +		const buffer = new Uint8Array(32);
    +		const svgHeader = [0x3c, 0x73, 0x76, 0x67];
    +		svgHeader.forEach((b, i) => (buffer[i] = b));
    +		expect(detectImageContentType(buffer)).toBe("image/svg+xml");
    +	});
    +
    +	it("should detect AVIF", () => {
    +		const buffer = new Uint8Array(32);
    +		const avifHeader = [0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66];
    +		// Bytes at positions 0-3 are wildcards (any non-zero matches), fill with typical values
    +		buffer[0] = 0x00;
    +		buffer[1] = 0x00;
    +		buffer[2] = 0x00;
    +		buffer[3] = 0x1c; // non-zero (file size prefix); the detection uses !b to skip zero bytes
    +		avifHeader.forEach((b, i) => {
    +			if (b !== 0) buffer[i] = b;
    +		});
    +		expect(detectImageContentType(buffer)).toBe("image/avif");
    +	});
    +
    +	it("should detect ICO", () => {
    +		const buffer = new Uint8Array(32);
    +		const icoHeader = [0x00, 0x00, 0x01, 0x00];
    +		icoHeader.forEach((b, i) => (buffer[i] = b));
    +		expect(detectImageContentType(buffer)).toBe("image/x-icon");
    +	});
    +
    +	it("should detect TIFF", () => {
    +		const buffer = new Uint8Array(32);
    +		const tiffHeader = [0x49, 0x49, 0x2a, 0x00];
    +		tiffHeader.forEach((b, i) => (buffer[i] = b));
    +		expect(detectImageContentType(buffer)).toBe("image/tiff");
    +	});
    +
    +	it("should detect BMP", () => {
    +		const buffer = new Uint8Array(32);
    +		buffer[0] = 0x42;
    +		buffer[1] = 0x4d;
    +		expect(detectImageContentType(buffer)).toBe("image/bmp");
    +	});
    +
    +	it("should detect JXL (short signature)", () => {
    +		const buffer = new Uint8Array(32);
    +		buffer[0] = 0xff;
    +		buffer[1] = 0x0a;
    +		expect(detectImageContentType(buffer)).toBe("image/jxl");
    +	});
    +
    +	it("should detect JXL (long signature)", () => {
    +		const buffer = new Uint8Array(32);
    +		const jxlHeader = [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a];
    +		jxlHeader.forEach((b, i) => (buffer[i] = b));
    +		expect(detectImageContentType(buffer)).toBe("image/jxl");
    +	});
    +
    +	it("should return null for unknown content", () => {
    +		const buffer = new Uint8Array(32);
    +		buffer.fill(0x00);
    +		buffer[0] = 0x01;
    +		buffer[1] = 0x02;
    +		buffer[2] = 0x03;
    +		expect(detectImageContentType(buffer)).toBeNull();
    +	});
    +});
    
  • packages/cloudflare/src/cli/templates/images.ts+150 14 modified
    @@ -89,20 +89,11 @@ export async function handleImageRequest(
     		}
     	}
     
    -	const [contentTypeImageStream, imageStream] = imageResponse.body.tee();
    -	const imageHeaderBytes = new Uint8Array(32);
    -	const contentTypeImageReader = contentTypeImageStream.getReader({
    -		mode: "byob",
    -	});
    -	const readImageHeaderBytesResult = await contentTypeImageReader.readAtLeast(32, imageHeaderBytes);
    -	if (readImageHeaderBytesResult.value === undefined) {
    -		await imageResponse.body.cancel();
    -
    -		return new Response('"url" parameter is valid but upstream response is invalid', {
    -			status: 400,
    -		});
    +	const readHeaderResult = await readImageHeader(imageResponse);
    +	if (readHeaderResult instanceof Response) {
    +		return readHeaderResult;
     	}
    -	const contentType = detectImageContentType(readImageHeaderBytesResult.value);
    +	const { contentType, imageStream } = readHeaderResult;
     	if (contentType === null) {
     		warn(`Failed to detect content type of "${parseResult.url}"`);
     		return new Response('"url" parameter is valid but image type is not allowed', {
    @@ -183,6 +174,141 @@ export async function handleImageRequest(
     	return response;
     }
     
    +/**
    + * Handles requests to /cdn-cgi/image/ in development.
    + *
    + * Extracts the image URL, fetches the image, and checks the content type against
    + * Cloudflare's supported input formats.
    + *
    + * @param requestURL The full request URL.
    + * @param env The Cloudflare environment bindings.
    + * @returns A promise that resolves to the image response.
    + */
    +export async function handleCdnCgiImageRequest(requestURL: URL, env: CloudflareEnv): Promise<Response> {
    +	const parseResult = parseCdnCgiImageRequest(requestURL.pathname);
    +
    +	if (!parseResult.ok) {
    +		return new Response(parseResult.message, {
    +			status: 400,
    +		});
    +	}
    +
    +	let imageResponse: Response;
    +	if (parseResult.url.startsWith("/")) {
    +		if (env.ASSETS === undefined) {
    +			return new Response("env.ASSETS binding is not defined", {
    +				status: 404,
    +			});
    +		}
    +		const absoluteURL = new URL(parseResult.url, requestURL);
    +		imageResponse = await env.ASSETS.fetch(absoluteURL);
    +	} else {
    +		imageResponse = await fetch(parseResult.url);
    +	}
    +
    +	if (!imageResponse.ok || imageResponse.body === null) {
    +		return new Response('"url" parameter is valid but upstream response is invalid', {
    +			status: imageResponse.status,
    +		});
    +	}
    +
    +	const readHeaderResult = await readImageHeader(imageResponse);
    +	if (readHeaderResult instanceof Response) {
    +		return readHeaderResult;
    +	}
    +	const { contentType, imageStream } = readHeaderResult;
    +	if (contentType === null || !SUPPORTED_CDN_CGI_INPUT_TYPES.has(contentType)) {
    +		return new Response('"url" parameter is valid but image type is not allowed', {
    +			status: 400,
    +		});
    +	}
    +
    +	if (contentType === SVG && !__IMAGES_ALLOW_SVG__) {
    +		return new Response('"url" parameter is valid but image type is not allowed', {
    +			status: 400,
    +		});
    +	}
    +
    +	return new Response(imageStream, {
    +		headers: { "Content-Type": contentType },
    +	});
    +}
    +
    +/**
    + * Parses a /cdn-cgi/image/ request URL.
    + *
    + * Extracts the image URL from the `/cdn-cgi/image/<options>/<image-url>` path format.
    + * Rejects protocol-relative URLs (`//...`). The cdn-cgi options are not parsed or
    + * validated as they are Cloudflare's concern.
    + *
    + * @param pathname The URL pathname (e.g. `/cdn-cgi/image/width=640,quality=75,format=auto/path/to/image.png`).
    + * @returns the parsed URL result or an error.
    + */
    +export function parseCdnCgiImageRequest(
    +	pathname: string
    +): { ok: true; url: string; static: boolean } | ErrorResult {
    +	const match = pathname.match(/^\/cdn-cgi\/image\/(?<options>[^/]+)\/(?<url>.+)$/);
    +	if (
    +		match === null ||
    +		// Valid URLs have at least one option
    +		!match.groups?.options ||
    +		!match.groups?.url
    +	) {
    +		return { ok: false, message: "Invalid /cdn-cgi/image/ URL format" };
    +	}
    +
    +	const imageUrl = match.groups.url;
    +
    +	// The regex separator consumes one `/`, so if imageUrl starts with `/`
    +	// the original URL segment was protocol-relative (`//...`).
    +	if (imageUrl.startsWith("/")) {
    +		return { ok: false, message: '"url" parameter cannot be a protocol-relative URL (//)' };
    +	}
    +
    +	// Resolve the image URL: it may be absolute (https://...) or relative.
    +	let resolvedUrl: string;
    +	if (imageUrl.match(/^https?:\/\//)) {
    +		resolvedUrl = imageUrl;
    +	} else {
    +		// Relative URLs need a leading slash.
    +		resolvedUrl = `/${imageUrl}`;
    +	}
    +
    +	return {
    +		ok: true,
    +		url: resolvedUrl,
    +		static: false,
    +	};
    +}
    +
    +/**
    + * Reads the first 32 bytes of an image response to detect its content type.
    + *
    + * Tees the response body so the image stream can still be consumed after detection.
    + *
    + * @param imageResponse The image response whose body to read.
    + * @returns The detected content type and image stream, or an error Response if the header bytes
    + *   could not be read.
    + */
    +async function readImageHeader(
    +	imageResponse: Response
    +): Promise<{ contentType: ImageContentType | null; imageStream: ReadableStream } | Response> {
    +	// Note: imageResponse.body is non-null — callers check before calling.
    +	const [contentTypeStream, imageStream] = imageResponse.body!.tee();
    +	const headerBytes = new Uint8Array(32);
    +	const reader = contentTypeStream.getReader({ mode: "byob" });
    +	const readResult = await reader.readAtLeast(32, headerBytes);
    +	if (readResult.value === undefined) {
    +		await imageResponse.body!.cancel();
    +		return new Response('"url" parameter is valid but upstream response is invalid', {
    +			status: 400,
    +		});
    +	}
    +
    +	const contentType = detectImageContentType(readResult.value);
    +	return { contentType, imageStream };
    +}
    +
     /**
      * Fetch call with max redirects and timeouts.
      *
    @@ -352,6 +478,9 @@ type ErrorResult = {
     /**
      * Validates that there is exactly one "url" query parameter.
      *
    + * Checks length, protocol-relative URLs, local/remote pattern matching, recursion, and protocol.
    + *
    + * @param requestURL The request URL containing the "url" query parameter.
      * @returns the validated URL or an error result.
      */
     function validateUrlQueryParameter(requestURL: URL): ErrorResult | { url: string; static: boolean } {
    @@ -372,8 +501,8 @@ function validateUrlQueryParameter(requestURL: URL): ErrorResult | { url: string
     		return result;
     	}
     
    -	// The url parameter value should be a valid URL or a valid relative URL.
     	const url = urls[0]!;
    +
     	if (url.length > 3072) {
     		const result: ErrorResult = {
     			ok: false,
    @@ -631,6 +760,13 @@ const ICNS = "image/x-icns";
     const TIFF = "image/tiff";
     const BMP = "image/bmp";
     
    +/**
    + * Image content types supported as input by Cloudflare's cdn-cgi image transformation.
    + *
    + * @see https://developers.cloudflare.com/images/transform-images/#supported-input-formats
    + */
    +const SUPPORTED_CDN_CGI_INPUT_TYPES: ReadonlySet<string> = new Set([JPEG, PNG, GIF, WEBP, SVG, HEIC]);
    +
     type ImageContentType =
     	| "image/avif"
     	| "image/webp"
    
  • packages/cloudflare/src/cli/templates/worker.ts+2 9 modified
    @@ -1,5 +1,5 @@
     //@ts-expect-error: Will be resolved by wrangler build
    -import { handleImageRequest } from "./cloudflare/images.js";
    +import { handleCdnCgiImageRequest, handleImageRequest } from "./cloudflare/images.js";
     //@ts-expect-error: Will be resolved by wrangler build
     import { runWithCloudflareRequestContext } from "./cloudflare/init.js";
     //@ts-expect-error: Will be resolved by wrangler build
    @@ -27,14 +27,7 @@ export default {
     			// Serve images in development.
     			// Note: "/cdn-cgi/image/..." requests do not reach production workers.
     			if (url.pathname.startsWith("/cdn-cgi/image/")) {
    -				const m = url.pathname.match(/\/cdn-cgi\/image\/.+?\/(?<url>.+)$/);
    -				if (m === null) {
    -					return new Response("Not Found!", { status: 404 });
    -				}
    -				const imageUrl = m.groups!.url!;
    -				return imageUrl.match(/^https?:\/\//)
    -					? fetch(imageUrl, { cf: { cacheEverything: true } })
    -					: env.ASSETS?.fetch(new URL(`/${imageUrl}`, url));
    +				return handleCdnCgiImageRequest(url, env);
     			}
     
     			// Fallback for the Next default image loader.
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

8

News mentions

0

No linked articles in our index yet.