Skip to content

Commit f5bd138

Browse files
authored
make dev /cdn-cgi/image behaves like prod for consistency (#1147)
1 parent e965e4d commit f5bd138

3 files changed

Lines changed: 305 additions & 24 deletions

File tree

packages/cloudflare/src/cli/templates/images.spec.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import pm from "picomatch";
22
import { describe, expect, it } from "vitest";
33

44
import type { LocalPattern } from "./images.js";
5-
import { matchLocalPattern, matchRemotePattern as mRP } from "./images.js";
5+
import {
6+
detectImageContentType,
7+
matchLocalPattern,
8+
matchRemotePattern as mRP,
9+
parseCdnCgiImageRequest,
10+
} from "./images.js";
611

712
/**
813
* See https://github.com/vercel/next.js/blob/64702a9/test/unit/image-optimizer/match-remote-pattern.test.ts
@@ -426,3 +431,150 @@ describe("matchLocalPattern", () => {
426431
expect(mLP(p, "/path/to/file.txt?q=1&a=two")).toBe(true);
427432
});
428433
});
434+
435+
describe("parseCdnCgiImageRequest", () => {
436+
it("should parse a valid local image request", () => {
437+
const result = parseCdnCgiImageRequest(
438+
"/cdn-cgi/image/width=640,quality=75,format=auto/_next/static/media/photo.png"
439+
);
440+
expect(result).toEqual({ ok: true, url: "/_next/static/media/photo.png", static: false });
441+
});
442+
443+
it("should parse a valid remote image request", () => {
444+
const result = parseCdnCgiImageRequest(
445+
"/cdn-cgi/image/width=1080,quality=75,format=auto/https://example.com/photo.jpg"
446+
);
447+
expect(result).toEqual({ ok: true, url: "https://example.com/photo.jpg", static: false });
448+
});
449+
450+
it("should reject when pathname does not match /cdn-cgi/image/ format", () => {
451+
const result = parseCdnCgiImageRequest("/cdn-cgi/image/");
452+
expect(result).toEqual({ ok: false, message: "Invalid /cdn-cgi/image/ URL format" });
453+
});
454+
455+
it("should reject when options segment has no trailing image URL", () => {
456+
const result = parseCdnCgiImageRequest("/cdn-cgi/image/width=640");
457+
expect(result).toEqual({ ok: false, message: "Invalid /cdn-cgi/image/ URL format" });
458+
});
459+
460+
it("should reject protocol-relative URLs", () => {
461+
const result = parseCdnCgiImageRequest(
462+
"/cdn-cgi/image/width=640,quality=75,format=auto//evil.com/photo.jpg"
463+
);
464+
expect(result).toEqual({
465+
ok: false,
466+
message: '"url" parameter cannot be a protocol-relative URL (//)',
467+
});
468+
});
469+
470+
it("should add leading slash to relative image URLs", () => {
471+
const result = parseCdnCgiImageRequest(
472+
"/cdn-cgi/image/width=640,quality=75,format=auto/path/to/image.png"
473+
);
474+
expect(result).toMatchObject({ ok: true, url: "/path/to/image.png" });
475+
});
476+
});
477+
478+
describe("detectImageContentType", () => {
479+
it("should detect JPEG", () => {
480+
const buffer = new Uint8Array(32);
481+
buffer[0] = 0xff;
482+
buffer[1] = 0xd8;
483+
buffer[2] = 0xff;
484+
expect(detectImageContentType(buffer)).toBe("image/jpeg");
485+
});
486+
487+
it("should detect PNG", () => {
488+
const buffer = new Uint8Array(32);
489+
const pngHeader = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
490+
pngHeader.forEach((b, i) => (buffer[i] = b));
491+
expect(detectImageContentType(buffer)).toBe("image/png");
492+
});
493+
494+
it("should detect GIF", () => {
495+
const buffer = new Uint8Array(32);
496+
const gifHeader = [0x47, 0x49, 0x46, 0x38];
497+
gifHeader.forEach((b, i) => (buffer[i] = b));
498+
expect(detectImageContentType(buffer)).toBe("image/gif");
499+
});
500+
501+
it("should detect WebP", () => {
502+
const buffer = new Uint8Array(32);
503+
// RIFF....WEBP
504+
const webpHeader = [0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50];
505+
webpHeader.forEach((b, i) => (buffer[i] = b));
506+
expect(detectImageContentType(buffer)).toBe("image/webp");
507+
});
508+
509+
it("should detect SVG (<?xml prefix)", () => {
510+
const buffer = new Uint8Array(32);
511+
const svgHeader = [0x3c, 0x3f, 0x78, 0x6d, 0x6c];
512+
svgHeader.forEach((b, i) => (buffer[i] = b));
513+
expect(detectImageContentType(buffer)).toBe("image/svg+xml");
514+
});
515+
516+
it("should detect SVG (<svg prefix)", () => {
517+
const buffer = new Uint8Array(32);
518+
const svgHeader = [0x3c, 0x73, 0x76, 0x67];
519+
svgHeader.forEach((b, i) => (buffer[i] = b));
520+
expect(detectImageContentType(buffer)).toBe("image/svg+xml");
521+
});
522+
523+
it("should detect AVIF", () => {
524+
const buffer = new Uint8Array(32);
525+
const avifHeader = [0x00, 0x00, 0x00, 0x00, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66];
526+
// Bytes at positions 0-3 are wildcards (any non-zero matches), fill with typical values
527+
buffer[0] = 0x00;
528+
buffer[1] = 0x00;
529+
buffer[2] = 0x00;
530+
buffer[3] = 0x1c; // non-zero (file size prefix); the detection uses !b to skip zero bytes
531+
avifHeader.forEach((b, i) => {
532+
if (b !== 0) buffer[i] = b;
533+
});
534+
expect(detectImageContentType(buffer)).toBe("image/avif");
535+
});
536+
537+
it("should detect ICO", () => {
538+
const buffer = new Uint8Array(32);
539+
const icoHeader = [0x00, 0x00, 0x01, 0x00];
540+
icoHeader.forEach((b, i) => (buffer[i] = b));
541+
expect(detectImageContentType(buffer)).toBe("image/x-icon");
542+
});
543+
544+
it("should detect TIFF", () => {
545+
const buffer = new Uint8Array(32);
546+
const tiffHeader = [0x49, 0x49, 0x2a, 0x00];
547+
tiffHeader.forEach((b, i) => (buffer[i] = b));
548+
expect(detectImageContentType(buffer)).toBe("image/tiff");
549+
});
550+
551+
it("should detect BMP", () => {
552+
const buffer = new Uint8Array(32);
553+
buffer[0] = 0x42;
554+
buffer[1] = 0x4d;
555+
expect(detectImageContentType(buffer)).toBe("image/bmp");
556+
});
557+
558+
it("should detect JXL (short signature)", () => {
559+
const buffer = new Uint8Array(32);
560+
buffer[0] = 0xff;
561+
buffer[1] = 0x0a;
562+
expect(detectImageContentType(buffer)).toBe("image/jxl");
563+
});
564+
565+
it("should detect JXL (long signature)", () => {
566+
const buffer = new Uint8Array(32);
567+
const jxlHeader = [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a];
568+
jxlHeader.forEach((b, i) => (buffer[i] = b));
569+
expect(detectImageContentType(buffer)).toBe("image/jxl");
570+
});
571+
572+
it("should return null for unknown content", () => {
573+
const buffer = new Uint8Array(32);
574+
buffer.fill(0x00);
575+
buffer[0] = 0x01;
576+
buffer[1] = 0x02;
577+
buffer[2] = 0x03;
578+
expect(detectImageContentType(buffer)).toBeNull();
579+
});
580+
});

packages/cloudflare/src/cli/templates/images.ts

Lines changed: 150 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,20 +89,11 @@ export async function handleImageRequest(
8989
}
9090
}
9191

92-
const [contentTypeImageStream, imageStream] = imageResponse.body.tee();
93-
const imageHeaderBytes = new Uint8Array(32);
94-
const contentTypeImageReader = contentTypeImageStream.getReader({
95-
mode: "byob",
96-
});
97-
const readImageHeaderBytesResult = await contentTypeImageReader.readAtLeast(32, imageHeaderBytes);
98-
if (readImageHeaderBytesResult.value === undefined) {
99-
await imageResponse.body.cancel();
100-
101-
return new Response('"url" parameter is valid but upstream response is invalid', {
102-
status: 400,
103-
});
92+
const readHeaderResult = await readImageHeader(imageResponse);
93+
if (readHeaderResult instanceof Response) {
94+
return readHeaderResult;
10495
}
105-
const contentType = detectImageContentType(readImageHeaderBytesResult.value);
96+
const { contentType, imageStream } = readHeaderResult;
10697
if (contentType === null) {
10798
warn(`Failed to detect content type of "${parseResult.url}"`);
10899
return new Response('"url" parameter is valid but image type is not allowed', {
@@ -183,6 +174,141 @@ export async function handleImageRequest(
183174
return response;
184175
}
185176

177+
/**
178+
* Handles requests to /cdn-cgi/image/ in development.
179+
*
180+
* Extracts the image URL, fetches the image, and checks the content type against
181+
* Cloudflare's supported input formats.
182+
*
183+
* @param requestURL The full request URL.
184+
* @param env The Cloudflare environment bindings.
185+
* @returns A promise that resolves to the image response.
186+
*/
187+
export async function handleCdnCgiImageRequest(requestURL: URL, env: CloudflareEnv): Promise<Response> {
188+
const parseResult = parseCdnCgiImageRequest(requestURL.pathname);
189+
190+
if (!parseResult.ok) {
191+
return new Response(parseResult.message, {
192+
status: 400,
193+
});
194+
}
195+
196+
let imageResponse: Response;
197+
if (parseResult.url.startsWith("/")) {
198+
if (env.ASSETS === undefined) {
199+
return new Response("env.ASSETS binding is not defined", {
200+
status: 404,
201+
});
202+
}
203+
const absoluteURL = new URL(parseResult.url, requestURL);
204+
imageResponse = await env.ASSETS.fetch(absoluteURL);
205+
} else {
206+
imageResponse = await fetch(parseResult.url);
207+
}
208+
209+
if (!imageResponse.ok || imageResponse.body === null) {
210+
return new Response('"url" parameter is valid but upstream response is invalid', {
211+
status: imageResponse.status,
212+
});
213+
}
214+
215+
const readHeaderResult = await readImageHeader(imageResponse);
216+
if (readHeaderResult instanceof Response) {
217+
return readHeaderResult;
218+
}
219+
const { contentType, imageStream } = readHeaderResult;
220+
if (contentType === null || !SUPPORTED_CDN_CGI_INPUT_TYPES.has(contentType)) {
221+
return new Response('"url" parameter is valid but image type is not allowed', {
222+
status: 400,
223+
});
224+
}
225+
226+
if (contentType === SVG && !__IMAGES_ALLOW_SVG__) {
227+
return new Response('"url" parameter is valid but image type is not allowed', {
228+
status: 400,
229+
});
230+
}
231+
232+
return new Response(imageStream, {
233+
headers: { "Content-Type": contentType },
234+
});
235+
}
236+
237+
/**
238+
* Parses a /cdn-cgi/image/ request URL.
239+
*
240+
* Extracts the image URL from the `/cdn-cgi/image/<options>/<image-url>` path format.
241+
* Rejects protocol-relative URLs (`//...`). The cdn-cgi options are not parsed or
242+
* validated as they are Cloudflare's concern.
243+
*
244+
* @param pathname The URL pathname (e.g. `/cdn-cgi/image/width=640,quality=75,format=auto/path/to/image.png`).
245+
* @returns the parsed URL result or an error.
246+
*/
247+
export function parseCdnCgiImageRequest(
248+
pathname: string
249+
): { ok: true; url: string; static: boolean } | ErrorResult {
250+
const match = pathname.match(/^\/cdn-cgi\/image\/(?<options>[^/]+)\/(?<url>.+)$/);
251+
if (
252+
match === null ||
253+
// Valid URLs have at least one option
254+
!match.groups?.options ||
255+
!match.groups?.url
256+
) {
257+
return { ok: false, message: "Invalid /cdn-cgi/image/ URL format" };
258+
}
259+
260+
const imageUrl = match.groups.url;
261+
262+
// The regex separator consumes one `/`, so if imageUrl starts with `/`
263+
// the original URL segment was protocol-relative (`//...`).
264+
if (imageUrl.startsWith("/")) {
265+
return { ok: false, message: '"url" parameter cannot be a protocol-relative URL (//)' };
266+
}
267+
268+
// Resolve the image URL: it may be absolute (https://...) or relative.
269+
let resolvedUrl: string;
270+
if (imageUrl.match(/^https?:\/\//)) {
271+
resolvedUrl = imageUrl;
272+
} else {
273+
// Relative URLs need a leading slash.
274+
resolvedUrl = `/${imageUrl}`;
275+
}
276+
277+
return {
278+
ok: true,
279+
url: resolvedUrl,
280+
static: false,
281+
};
282+
}
283+
284+
/**
285+
* Reads the first 32 bytes of an image response to detect its content type.
286+
*
287+
* Tees the response body so the image stream can still be consumed after detection.
288+
*
289+
* @param imageResponse The image response whose body to read.
290+
* @returns The detected content type and image stream, or an error Response if the header bytes
291+
* could not be read.
292+
*/
293+
async function readImageHeader(
294+
imageResponse: Response
295+
): Promise<{ contentType: ImageContentType | null; imageStream: ReadableStream } | Response> {
296+
// Note: imageResponse.body is non-null — callers check before calling.
297+
const [contentTypeStream, imageStream] = imageResponse.body!.tee();
298+
const headerBytes = new Uint8Array(32);
299+
const reader = contentTypeStream.getReader({ mode: "byob" });
300+
const readResult = await reader.readAtLeast(32, headerBytes);
301+
if (readResult.value === undefined) {
302+
await imageResponse.body!.cancel();
303+
return new Response('"url" parameter is valid but upstream response is invalid', {
304+
status: 400,
305+
});
306+
}
307+
308+
const contentType = detectImageContentType(readResult.value);
309+
return { contentType, imageStream };
310+
}
311+
186312
/**
187313
* Fetch call with max redirects and timeouts.
188314
*
@@ -352,6 +478,9 @@ type ErrorResult = {
352478
/**
353479
* Validates that there is exactly one "url" query parameter.
354480
*
481+
* Checks length, protocol-relative URLs, local/remote pattern matching, recursion, and protocol.
482+
*
483+
* @param requestURL The request URL containing the "url" query parameter.
355484
* @returns the validated URL or an error result.
356485
*/
357486
function validateUrlQueryParameter(requestURL: URL): ErrorResult | { url: string; static: boolean } {
@@ -372,8 +501,8 @@ function validateUrlQueryParameter(requestURL: URL): ErrorResult | { url: string
372501
return result;
373502
}
374503

375-
// The url parameter value should be a valid URL or a valid relative URL.
376504
const url = urls[0]!;
505+
377506
if (url.length > 3072) {
378507
const result: ErrorResult = {
379508
ok: false,
@@ -631,6 +760,13 @@ const ICNS = "image/x-icns";
631760
const TIFF = "image/tiff";
632761
const BMP = "image/bmp";
633762

763+
/**
764+
* Image content types supported as input by Cloudflare's cdn-cgi image transformation.
765+
*
766+
* @see https://developers.cloudflare.com/images/transform-images/#supported-input-formats
767+
*/
768+
const SUPPORTED_CDN_CGI_INPUT_TYPES: ReadonlySet<string> = new Set([JPEG, PNG, GIF, WEBP, SVG, HEIC]);
769+
634770
type ImageContentType =
635771
| "image/avif"
636772
| "image/webp"

packages/cloudflare/src/cli/templates/worker.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//@ts-expect-error: Will be resolved by wrangler build
2-
import { handleImageRequest } from "./cloudflare/images.js";
2+
import { handleCdnCgiImageRequest, handleImageRequest } from "./cloudflare/images.js";
33
//@ts-expect-error: Will be resolved by wrangler build
44
import { runWithCloudflareRequestContext } from "./cloudflare/init.js";
55
//@ts-expect-error: Will be resolved by wrangler build
@@ -27,14 +27,7 @@ export default {
2727
// Serve images in development.
2828
// Note: "/cdn-cgi/image/..." requests do not reach production workers.
2929
if (url.pathname.startsWith("/cdn-cgi/image/")) {
30-
const m = url.pathname.match(/\/cdn-cgi\/image\/.+?\/(?<url>.+)$/);
31-
if (m === null) {
32-
return new Response("Not Found!", { status: 404 });
33-
}
34-
const imageUrl = m.groups!.url!;
35-
return imageUrl.match(/^https?:\/\//)
36-
? fetch(imageUrl, { cf: { cacheEverything: true } })
37-
: env.ASSETS?.fetch(new URL(`/${imageUrl}`, url));
30+
return handleCdnCgiImageRequest(url, env);
3831
}
3932

4033
// Fallback for the Next default image loader.

0 commit comments

Comments
 (0)