Skip to content

Commit 8902280

Browse files
committed
fix(image): run local source requests through middleware
1 parent 2efac2c commit 8902280

16 files changed

Lines changed: 774 additions & 143 deletions

packages/vinext/src/config/next-config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,10 @@ export type NextConfig = {
220220
dangerouslyAllowSVG?: boolean;
221221
/** Allow image optimization for hostnames that resolve to private IP addresses. This is a security risk (SSRF) — only enable for private networks when you understand the risk. */
222222
dangerouslyAllowLocalIP?: boolean;
223+
/** Maximum image source response body size in bytes. Defaults to 50 MB. */
224+
maximumResponseBody?: number;
225+
/** Minimum optimized image cache lifetime in seconds. Defaults to 4 hours. */
226+
minimumCacheTTL?: number;
223227
/** Content-Disposition header for image responses. Defaults to "inline". */
224228
contentDispositionType?: "inline" | "attachment";
225229
/** Content-Security-Policy header for image responses. Defaults to "script-src 'none'; frame-src 'none'; sandbox;" */

packages/vinext/src/entries/app-rsc-entry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ export function generateRscEntry(
244244
qualities: config?.imageConfig?.qualities,
245245
dangerouslyAllowSVG: config?.imageConfig?.dangerouslyAllowSVG,
246246
dangerouslyAllowLocalIP: config?.imageConfig?.dangerouslyAllowLocalIP,
247+
maximumResponseBody: config?.imageConfig?.maximumResponseBody,
248+
minimumCacheTTL: config?.imageConfig?.minimumCacheTTL,
247249
contentDispositionType: config?.imageConfig?.contentDispositionType,
248250
contentSecurityPolicy: config?.imageConfig?.contentSecurityPolicy,
249251
};

packages/vinext/src/entries/pages-server-entry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ export async function generateServerEntry(
135135
qualities: nextConfig?.images?.qualities,
136136
dangerouslyAllowSVG: nextConfig?.images?.dangerouslyAllowSVG,
137137
dangerouslyAllowLocalIP: nextConfig?.images?.dangerouslyAllowLocalIP,
138+
maximumResponseBody: nextConfig?.images?.maximumResponseBody,
139+
minimumCacheTTL: nextConfig?.images?.minimumCacheTTL,
138140
contentDispositionType: nextConfig?.images?.contentDispositionType,
139141
contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy,
140142
},

packages/vinext/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3608,6 +3608,8 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] {
36083608
qualities: nextConfig?.images?.qualities,
36093609
dangerouslyAllowSVG: nextConfig?.images?.dangerouslyAllowSVG,
36103610
dangerouslyAllowLocalIP: nextConfig?.images?.dangerouslyAllowLocalIP,
3611+
maximumResponseBody: nextConfig?.images?.maximumResponseBody,
3612+
minimumCacheTTL: nextConfig?.images?.minimumCacheTTL,
36113613
contentDispositionType: nextConfig?.images?.contentDispositionType,
36123614
contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy,
36133615
},

packages/vinext/src/server/app-router-entry.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters";
3535
// @ts-expect-error -- virtual module resolved by vinext at build time
3636
import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters";
3737
import {
38+
createInternalImageRequest,
3839
getImageOptimizer,
3940
handleConfiguredImageOptimization,
4041
isImageOptimizationPath,
@@ -93,11 +94,26 @@ async function handleRequest(
9394
const url = new URL(request.url);
9495

9596
if (isImageOptimizationPath(url.pathname) && env?.ASSETS && getImageOptimizer()) {
96-
const assetFetcher = env.ASSETS;
9797
return handleConfiguredImageOptimization(
9898
request,
99-
(assetPath) =>
100-
Promise.resolve(assetFetcher.fetch(new Request(new URL(assetPath, request.url)))),
99+
async (assetPath, optimizerRequest) => {
100+
const sourceRequest = createInternalImageRequest(
101+
assetPath,
102+
optimizerRequest,
103+
__workerBasePath,
104+
);
105+
if (!sourceRequest) return new Response("Bad Request", { status: 400 });
106+
if (
107+
isNextStaticPath(
108+
new URL(sourceRequest.url).pathname,
109+
__workerBasePath,
110+
__workerAssetPathPrefix,
111+
)
112+
) {
113+
return env.ASSETS!.fetch(sourceRequest);
114+
}
115+
return handleRequest(sourceRequest, env, ctx);
116+
},
101117
__rscImageAllowedWidths,
102118
__rscImageConfig,
103119
);

packages/vinext/src/server/image-optimization.ts

Lines changed: 195 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919

2020
import { badRequestResponse } from "./http-error-responses.js";
21+
import { stripBasePath } from "../utils/base-path.js";
2122

2223
/** The pathname that triggers image optimization (matches Next.js). */
2324
export const IMAGE_OPTIMIZATION_PATH = "/_next/image";
@@ -76,6 +77,10 @@ export type ImageConfig = {
7677
* via the image shim instead.
7778
*/
7879
dangerouslyAllowLocalIP?: boolean;
80+
/** Maximum source response body size. Defaults to 50 MB. */
81+
maximumResponseBody?: number;
82+
/** Minimum optimized image cache lifetime in seconds. Defaults to 4 hours. */
83+
minimumCacheTTL?: number;
7984
/** Content-Disposition header value. Default: "inline". */
8085
contentDispositionType?: "inline" | "attachment";
8186
/** Content-Security-Policy header value. Default: "script-src 'none'; frame-src 'none'; sandbox;" */
@@ -201,7 +206,7 @@ export function negotiateImageFormat(acceptHeader: string | null): string {
201206
* Standard Cache-Control header for optimized images.
202207
* Optimized images are immutable because the URL encodes the transform params.
203208
*/
204-
export const IMAGE_CACHE_CONTROL = "public, max-age=31536000, immutable";
209+
export const IMAGE_CACHE_CONTROL = "public, max-age=14400, must-revalidate";
205210

206211
/**
207212
* Content-Security-Policy for image optimization responses.
@@ -222,7 +227,11 @@ const SAFE_IMAGE_CONTENT_TYPES = new Set([
222227
"image/gif",
223228
"image/webp",
224229
"image/avif",
230+
"image/heic",
231+
"image/jp2",
232+
"image/jxl",
225233
"image/x-icon",
234+
"image/x-icns",
226235
"image/vnd.microsoft.icon",
227236
"image/bmp",
228237
"image/tiff",
@@ -244,6 +253,96 @@ export function isSafeImageContentType(
244253
return false;
245254
}
246255

256+
async function readImageSource(
257+
response: Response,
258+
maximumResponseBody: number,
259+
): Promise<
260+
{ response: Response; contentType: string | null; tooLarge: false } | { tooLarge: true } | null
261+
> {
262+
if (!response.body) return null;
263+
const reader = response.body.getReader();
264+
const chunks: Uint8Array[] = [];
265+
let totalSize = 0;
266+
while (true) {
267+
const { done, value } = await reader.read();
268+
if (done) break;
269+
totalSize += value.byteLength;
270+
if (totalSize > maximumResponseBody) {
271+
await reader.cancel();
272+
return { tooLarge: true };
273+
}
274+
chunks.push(value);
275+
}
276+
const bytes = new Uint8Array(totalSize);
277+
let offset = 0;
278+
for (const chunk of chunks) {
279+
bytes.set(chunk, offset);
280+
offset += chunk.byteLength;
281+
}
282+
const startsWith = (...signature: number[]) =>
283+
bytes.length >= signature.length && signature.every((byte, index) => bytes[index] === byte);
284+
const textPrefix = new TextDecoder()
285+
.decode(bytes.subarray(0, 256))
286+
.replace(/^\uFEFF/, "")
287+
.trimStart()
288+
.toLowerCase();
289+
let contentType: string | null = null;
290+
if (startsWith(0xff, 0xd8, 0xff)) contentType = "image/jpeg";
291+
else if (startsWith(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) contentType = "image/png";
292+
else if (startsWith(0x47, 0x49, 0x46, 0x38)) contentType = "image/gif";
293+
if (
294+
startsWith(0x52, 0x49, 0x46, 0x46) &&
295+
bytes.length >= 12 &&
296+
bytes[8] === 0x57 &&
297+
bytes[9] === 0x45 &&
298+
bytes[10] === 0x42 &&
299+
bytes[11] === 0x50
300+
)
301+
contentType = "image/webp";
302+
else if (bytes.length >= 12 && startsWith(0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20))
303+
contentType = "image/jxl";
304+
else if (startsWith(0xff, 0x0a)) contentType = "image/jxl";
305+
else if (bytes.length >= 12 && startsWith(0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20))
306+
contentType = "image/jp2";
307+
else if (
308+
bytes.length >= 12 &&
309+
bytes[4] === 0x66 &&
310+
bytes[5] === 0x74 &&
311+
bytes[6] === 0x79 &&
312+
bytes[7] === 0x70
313+
) {
314+
const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
315+
if (brand === "avif" || brand === "avis") contentType = "image/avif";
316+
else if (brand === "heic") contentType = "image/heic";
317+
} else if (startsWith(0x00, 0x00, 0x01, 0x00)) contentType = "image/x-icon";
318+
else if (startsWith(0x69, 0x63, 0x6e, 0x73)) contentType = "image/x-icns";
319+
else if (startsWith(0x42, 0x4d)) contentType = "image/bmp";
320+
else if (startsWith(0x49, 0x49, 0x2a, 0x00) || startsWith(0x4d, 0x4d, 0x00, 0x2a))
321+
contentType = "image/tiff";
322+
else if (textPrefix.startsWith("<?xml") || textPrefix.startsWith("<svg"))
323+
contentType = "image/svg+xml";
324+
325+
return {
326+
response: new Response(bytes, {
327+
status: response.status,
328+
statusText: response.statusText,
329+
headers: response.headers,
330+
}),
331+
contentType,
332+
tooLarge: false,
333+
};
334+
}
335+
336+
function isFreshImageRequest(request: Request, etag: string): boolean {
337+
if (request.headers.get("Cache-Control")?.toLowerCase().includes("no-cache")) return false;
338+
const ifNoneMatch = request.headers.get("If-None-Match");
339+
if (!ifNoneMatch) return false;
340+
const normalize = (value: string) => value.trim().replace(/^W\//, "");
341+
return ifNoneMatch
342+
.split(",")
343+
.some((value) => value.trim() === "*" || normalize(value) === normalize(etag));
344+
}
345+
247346
/**
248347
* Apply security headers to an image optimization response.
249348
* These headers are set on every response from the image endpoint,
@@ -262,14 +361,37 @@ function setImageSecurityHeaders(headers: Headers, config?: ImageConfig): void {
262361
);
263362
}
264363

265-
function createPassthroughImageResponse(source: Response, config?: ImageConfig): Response {
266-
const headers = new Headers(source.headers);
267-
headers.set("Cache-Control", IMAGE_CACHE_CONTROL);
364+
function createPassthroughImageResponse(
365+
source: Response,
366+
config?: ImageConfig,
367+
request?: Request,
368+
detectedContentType?: string,
369+
): Response {
370+
const headers = new Headers();
371+
const contentType = detectedContentType ?? source.headers.get("Content-Type");
372+
const etag = source.headers.get("ETag");
373+
if (contentType) headers.set("Content-Type", contentType);
374+
if (etag) headers.set("ETag", etag);
375+
headers.set("Cache-Control", imageCacheControl(source, config));
268376
headers.set("Vary", "Accept");
269377
setImageSecurityHeaders(headers, config);
378+
if (etag && request && isFreshImageRequest(request, etag)) {
379+
headers.delete("Content-Type");
380+
return new Response(null, { status: 304, headers });
381+
}
270382
return new Response(source.body, { status: 200, headers });
271383
}
272384

385+
function imageCacheControl(source: Response, config?: ImageConfig): string {
386+
const upstreamCacheControl = source.headers.get("Cache-Control");
387+
if (upstreamCacheControl?.toLowerCase().includes("immutable")) return upstreamCacheControl;
388+
const upstreamMaxAge = source.headers
389+
.get("Cache-Control")
390+
?.match(/(?:^|,)\s*(?:s-maxage|max-age)=(\d+)/i)?.[1];
391+
const maxAge = Math.max(config?.minimumCacheTTL ?? 14_400, Number(upstreamMaxAge ?? 0));
392+
return `public, max-age=${maxAge}, must-revalidate`;
393+
}
394+
273395
/**
274396
* Handlers for image optimization I/O operations.
275397
* Workers provide these callbacks to adapt their specific bindings.
@@ -284,6 +406,29 @@ export type ImageHandlers = {
284406
) => Promise<Response>;
285407
};
286408

409+
/**
410+
* Build the request used to resolve a local image source through the app's
411+
* normal request pipeline. This uses a credential-free synthetic GET request,
412+
* matching Next.js and preventing external rewrites from forwarding caller credentials.
413+
*/
414+
export function createInternalImageRequest(
415+
imageUrl: string,
416+
request: Request,
417+
basePath = "",
418+
): Request | null {
419+
const sourceUrl = new URL(imageUrl, request.url);
420+
let sourcePathname: string;
421+
try {
422+
sourcePathname = decodeURIComponent(sourceUrl.pathname);
423+
} catch {
424+
return null;
425+
}
426+
if (isImageOptimizationPath(stripBasePath(sourcePathname, basePath))) return null;
427+
return new Request(sourceUrl, {
428+
method: "GET",
429+
});
430+
}
431+
287432
/**
288433
* Handle image optimization requests.
289434
*
@@ -307,9 +452,19 @@ export async function handleImageOptimization(
307452
const { imageUrl, width, quality } = params;
308453

309454
// Fetch source image
310-
const source = await handlers.fetchAsset(imageUrl, request);
311-
if (!source.ok || !source.body) {
312-
return new Response("Image not found", { status: 404 });
455+
const sourceResult = await readImageSource(
456+
await handlers.fetchAsset(imageUrl, request),
457+
imageConfig?.maximumResponseBody ?? 50_000_000,
458+
);
459+
if (sourceResult?.tooLarge) {
460+
return new Response("The requested resource is too large.", { status: 413 });
461+
}
462+
if (!sourceResult) {
463+
return new Response("The requested resource isn't a valid image.", { status: 400 });
464+
}
465+
const { response: source, contentType: sourceContentType } = sourceResult;
466+
if (!sourceContentType) {
467+
return new Response("The requested resource isn't a valid image.", { status: 400 });
313468
}
314469

315470
// Negotiate output format from Accept header
@@ -318,29 +473,34 @@ export async function handleImageOptimization(
318473
// Block unsafe Content-Types (e.g., SVG which can contain embedded scripts).
319474
// Check the source Content-Type before any processing. SVG is only allowed
320475
// when dangerouslyAllowSVG is explicitly enabled in next.config.js.
321-
const sourceContentType = source.headers.get("Content-Type");
322476
if (!isSafeImageContentType(sourceContentType, imageConfig?.dangerouslyAllowSVG)) {
323477
return new Response("The requested resource is not an allowed image type", { status: 400 });
324478
}
325479

326480
// SVG passthrough: SVG is a vector format, so transformation (resize, format
327481
// conversion) provides no benefit. Serve as-is with security headers.
328482
// This matches Next.js behavior where SVG is a "bypass type".
329-
const sourceMediaType = sourceContentType?.split(";")[0].trim().toLowerCase();
330-
if (sourceMediaType === "image/svg+xml") {
331-
return createPassthroughImageResponse(source, imageConfig);
483+
if (sourceContentType === "image/svg+xml") {
484+
return createPassthroughImageResponse(source, imageConfig, request, sourceContentType);
332485
}
333486

334487
// Transform if handler provided, otherwise serve original
335488
if (handlers.transformImage) {
336489
try {
337-
const transformed = await handlers.transformImage(source.body, {
490+
const transformed = await handlers.transformImage(source.body!, {
338491
width,
339492
format,
340493
quality,
341494
});
342-
const headers = new Headers(transformed.headers);
343-
headers.set("Cache-Control", IMAGE_CACHE_CONTROL);
495+
if (!transformed.ok || !transformed.body) {
496+
throw new Error(`Image transform returned ${transformed.status}`);
497+
}
498+
const headers = new Headers();
499+
const transformedContentType = transformed.headers.get("Content-Type");
500+
const transformedEtag = transformed.headers.get("ETag");
501+
if (transformedContentType) headers.set("Content-Type", transformedContentType);
502+
if (transformedEtag) headers.set("ETag", transformedEtag);
503+
headers.set("Cache-Control", imageCacheControl(source, imageConfig));
344504
headers.set("Vary", "Accept");
345505
setImageSecurityHeaders(headers, imageConfig);
346506

@@ -350,6 +510,11 @@ export async function handleImageOptimization(
350510
headers.set("Content-Type", format);
351511
}
352512

513+
if (transformedEtag && isFreshImageRequest(request, transformedEtag)) {
514+
headers.delete("Content-Type");
515+
return new Response(null, { status: 304, headers });
516+
}
517+
353518
return new Response(transformed.body, { status: 200, headers });
354519
} catch (e) {
355520
console.error("[vinext] Image optimization error:", e);
@@ -358,20 +523,31 @@ export async function handleImageOptimization(
358523

359524
// Fallback: serve original image with cache headers
360525
try {
361-
return createPassthroughImageResponse(source, imageConfig);
526+
return createPassthroughImageResponse(source, imageConfig, request, sourceContentType);
362527
} catch (e) {
363528
console.error("[vinext] Image fallback error, refetching source image:", e);
364-
const refetchedSource = await handlers.fetchAsset(imageUrl, request);
365-
if (!refetchedSource.ok || !refetchedSource.body) {
529+
const refetchedResult = await readImageSource(
530+
await handlers.fetchAsset(imageUrl, request),
531+
imageConfig?.maximumResponseBody ?? 50_000_000,
532+
);
533+
if (refetchedResult?.tooLarge) {
534+
return new Response("The requested resource is too large.", { status: 413 });
535+
}
536+
if (!refetchedResult || !refetchedResult.response.ok) {
366537
return new Response("Image not found", { status: 404 });
367538
}
368539

369-
const refetchedContentType = refetchedSource.headers.get("Content-Type");
540+
const { response: refetchedSource, contentType: refetchedContentType } = refetchedResult;
370541
if (!isSafeImageContentType(refetchedContentType, imageConfig?.dangerouslyAllowSVG)) {
371542
return new Response("The requested resource is not an allowed image type", { status: 400 });
372543
}
373544

374-
return createPassthroughImageResponse(refetchedSource, imageConfig);
545+
return createPassthroughImageResponse(
546+
refetchedSource,
547+
imageConfig,
548+
request,
549+
refetchedContentType ?? undefined,
550+
);
375551
}
376552
}
377553

0 commit comments

Comments
 (0)