SSRF vulnerability in opennextjs-cloudflare via /cdn-cgi/ path normalization bypass
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.
| Package | Affected versions | Patched versions |
|---|---|---|
@opennextjs/cloudflarenpm | < 1.17.1 | 1.17.1 |
Affected products
3- opennextjs/@opennextjs/cloudflarev5Range: 0
Patches
1f5bd138fd3c7make dev /cdn-cgi/image behaves like prod for consistency (#1147)
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- github.com/opennextjs/opennextjs-cloudflare/pull/1147ghsapatchWEB
- github.com/advisories/GHSA-c7mq-gh6q-6q7cghsaADVISORY
- github.com/advisories/GHSA-rvpw-p7vw-wj3mghsarelatedADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-3125ghsaADVISORY
- github.com/opennextjs/opennextjs-cloudflare/commit/f5bd138fd3c77e02f2aa4b9c76d55681e59e98b4ghsaWEB
- github.com/opennextjs/opennextjs-cloudflare/security/advisories/GHSA-c7mq-gh6q-6q7cghsaWEB
- www.cve.org/cverecordghsarelatedWEB
- www.npmjs.com/package/@opennextjs/cloudflare/v/1.17.1ghsaproductWEB
News mentions
0No linked articles in our index yet.