Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/vinext/src/server/app-router-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { registerConfiguredCacheAdapters } from "virtual:vinext-cache-adapters";
// @ts-expect-error -- virtual module resolved by vinext at build time
import { registerConfiguredImageOptimizer } from "virtual:vinext-image-adapters";
import {
createInternalImageRequest,
getImageOptimizer,
handleConfiguredImageOptimization,
isImageOptimizationPath,
Expand Down Expand Up @@ -93,11 +94,18 @@ async function handleRequest(
const url = new URL(request.url);

if (isImageOptimizationPath(url.pathname) && env?.ASSETS && getImageOptimizer()) {
const assetFetcher = env.ASSETS;
return handleConfiguredImageOptimization(
request,
(assetPath) =>
Promise.resolve(assetFetcher.fetch(new Request(new URL(assetPath, request.url)))),
(assetPath, optimizerRequest) => {
const sourceRequest = createInternalImageRequest(
assetPath,
optimizerRequest,
__workerBasePath,
);
return sourceRequest
? handleRequest(sourceRequest, env, ctx)
: Promise.resolve(new Response("Bad Request", { status: 400 }));
},
__rscImageAllowedWidths,
__rscImageConfig,
);
Expand Down
43 changes: 42 additions & 1 deletion packages/vinext/src/server/image-optimization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import { badRequestResponse } from "./http-error-responses.js";
import { addBasePathToPathname, stripBasePath } from "../utils/base-path.js";

/** The pathname that triggers image optimization (matches Next.js). */
export const IMAGE_OPTIMIZATION_PATH = "/_next/image";
Expand Down Expand Up @@ -49,6 +50,23 @@ export function isImageOptimizationPath(pathname: string): boolean {
return pathname === IMAGE_OPTIMIZATION_PATH || pathname === VINEXT_IMAGE_OPTIMIZATION_PATH;
}

export function createInternalImageRequest(
imagePath: string,
request: Request,
basePath = "",
): Request | null {
const url = new URL(imagePath, request.url);
let pathname: string;
try {
pathname = decodeURIComponent(url.pathname).replaceAll("\\", "/");
} catch {
return null;
}
if (isImageOptimizationPath(stripBasePath(pathname, basePath))) return null;
url.pathname = addBasePathToPathname(url.pathname, basePath);
return new Request(url, { method: "GET" });
}

/**
* Image security configuration from next.config.js `images` section.
* Controls SVG handling and security headers for the image endpoint.
Expand Down Expand Up @@ -263,13 +281,26 @@ function setImageSecurityHeaders(headers: Headers, config?: ImageConfig): void {
}

function createPassthroughImageResponse(source: Response, config?: ImageConfig): Response {
const headers = new Headers(source.headers);
const headers = new Headers();
for (const name of ["Content-Type", "ETag"] as const) {
const value = source.headers.get(name);
if (value) headers.set(name, value);
}
headers.set("Cache-Control", IMAGE_CACHE_CONTROL);
headers.set("Vary", "Accept");
setImageSecurityHeaders(headers, config);
return new Response(source.body, { status: 200, headers });
}

export function imageSourceResponseHeaders(source: Response): Headers {
const headers = new Headers();
for (const name of ["Content-Type", "Cache-Control", "ETag"] as const) {
const value = source.headers.get(name);
if (value) headers.set(name, value);
}
return headers;
}

/**
* Handlers for image optimization I/O operations.
* Workers provide these callbacks to adapt their specific bindings.
Expand All @@ -284,6 +315,12 @@ export type ImageHandlers = {
) => Promise<Response>;
};

async function cancelResponseBody(response: Response): Promise<void> {
try {
await response.body?.cancel();
} catch {}
}

/**
* Handle image optimization requests.
*
Expand All @@ -309,6 +346,7 @@ export async function handleImageOptimization(
// Fetch source image
const source = await handlers.fetchAsset(imageUrl, request);
if (!source.ok || !source.body) {
await cancelResponseBody(source);
return new Response("Image not found", { status: 404 });
}

Expand All @@ -320,6 +358,7 @@ export async function handleImageOptimization(
// when dangerouslyAllowSVG is explicitly enabled in next.config.js.
const sourceContentType = source.headers.get("Content-Type");
if (!isSafeImageContentType(sourceContentType, imageConfig?.dangerouslyAllowSVG)) {
await cancelResponseBody(source);
return new Response("The requested resource is not an allowed image type", { status: 400 });
}

Expand Down Expand Up @@ -363,11 +402,13 @@ export async function handleImageOptimization(
console.error("[vinext] Image fallback error, refetching source image:", e);
const refetchedSource = await handlers.fetchAsset(imageUrl, request);
if (!refetchedSource.ok || !refetchedSource.body) {
await cancelResponseBody(refetchedSource);
return new Response("Image not found", { status: 404 });
}

const refetchedContentType = refetchedSource.headers.get("Content-Type");
if (!isSafeImageContentType(refetchedContentType, imageConfig?.dangerouslyAllowSVG)) {
await cancelResponseBody(refetchedSource);
return new Response("The requested resource is not an allowed image type", { status: 400 });
}

Expand Down
9 changes: 7 additions & 2 deletions packages/vinext/src/server/pages-router-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from "./pages-request-pipeline.js";
import type { PagesPipelineDeps } from "./pages-request-pipeline.js";
import {
createInternalImageRequest,
DEFAULT_DEVICE_SIZES,
DEFAULT_IMAGE_SIZES,
handleConfiguredImageOptimization,
Expand Down Expand Up @@ -160,8 +161,12 @@ async function handleRequest(
];
return handleConfiguredImageOptimization(
request,
(assetPath) =>
Promise.resolve(env.ASSETS!.fetch(new Request(new URL(assetPath, request.url)))),
(assetPath, optimizerRequest) => {
const sourceRequest = createInternalImageRequest(assetPath, optimizerRequest, basePath);
return sourceRequest
? handleRequest(sourceRequest, env, ctx)
: Promise.resolve(new Response("Bad Request", { status: 400 }));
},
allowedWidths,
imageConfig,
);
Expand Down
129 changes: 89 additions & 40 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import path from "pathslash";
import zlib from "node:zlib";
import { StaticFileCache, CONTENT_TYPES, etagFromFilenameHash } from "./static-file-cache.js";
import {
createInternalImageRequest,
imageSourceResponseHeaders,
isImageOptimizationPath,
IMAGE_CONTENT_SECURITY_POLICY,
parseImageParams,
Expand Down Expand Up @@ -1037,6 +1039,36 @@ type WorkerAppRouterEntry = {
fetch(request: Request, env?: unknown, ctx?: ExecutionContextLike): Promise<Response> | Response;
};

function internalImageOrigin(host: string, port: number): string {
const loopbackHost = host === "0.0.0.0" ? "127.0.0.1" : host === "::" ? "::1" : host;
const internalHost = loopbackHost.includes(":") ? `[${loopbackHost}]` : loopbackHost;
return `http://${internalHost}:${port}`;
}

async function fetchInternalImageSource(
sourceRequest: Request,
host: string,
port: number,
): Promise<Response> {
const publicUrl = new URL(sourceRequest.url);
const sourceUrl = new URL(sourceRequest.url);
const internalOrigin = new URL(internalImageOrigin(host, port));
sourceUrl.protocol = internalOrigin.protocol;
sourceUrl.host = internalOrigin.host;
try {
return await fetch(sourceUrl, {
headers: {
"accept-encoding": "identity",
host: publicUrl.host,
"x-forwarded-proto": publicUrl.protocol.slice(0, -1),
},
redirect: "manual",
});
} catch {
return new Response(null, { status: 502 });
}
}

function createNodeExecutionContext(): ExecutionContextLike {
return {
waitUntil(promise: Promise<unknown>) {
Expand Down Expand Up @@ -1310,6 +1342,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
// stat() calls — all lookups are pure in-memory Map.get(). Precompressed
// .br/.gz/.zst variants (generated at build time) are detected automatically.
const staticCache = await StaticFileCache.create(clientDir);
let listeningPort = port;

const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const rawUrl = req.url ?? "/";
Expand Down Expand Up @@ -1399,15 +1432,6 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
res.end("Bad Request");
return;
}
// Block SVG and other unsafe content types by checking the file extension.
// SVG is only allowed when dangerouslyAllowSVG is enabled in next.config.js.
const ext = path.extname(params.imageUrl).toLowerCase();
const ct = CONTENT_TYPES[ext] ?? "application/octet-stream";
if (!isSafeImageContentType(ct, imageConfig?.dangerouslyAllowSVG)) {
res.writeHead(400);
res.end("The requested resource is not an allowed image type");
return;
}
// Serve the original image with CSP and security headers
const imageSecurityHeaders: Record<string, string> = {
"Content-Security-Policy":
Expand All @@ -1416,19 +1440,36 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
"Content-Disposition":
imageConfig?.contentDispositionType === "attachment" ? "attachment" : "inline",
};
if (
await tryServeStatic(
req,
res,
clientDir,
params.imageUrl,
false,
staticCache,
imageSecurityHeaders,
)
) {
const optimizerRequest = nodeToWebRequest(req, rawUrl, prerenderSecret);
const sourceRequest = createInternalImageRequest(
params.imageUrl,
optimizerRequest,
appRouterBasePath,
);
if (!sourceRequest) {
res.writeHead(400);
res.end("Bad Request");
return;
}
const source = await fetchInternalImageSource(sourceRequest, host, listeningPort);
if (source.ok && source.body) {
if (
!isSafeImageContentType(
source.headers.get("Content-Type"),
imageConfig?.dangerouslyAllowSVG,
)
) {
cancelResponseBody(source);
res.writeHead(400);
res.end("The requested resource is not an allowed image type");
return;
}
const headers = imageSourceResponseHeaders(source);
for (const [name, value] of Object.entries(imageSecurityHeaders)) headers.set(name, value);
await sendWebResponse(new Response(source.body, { status: 200, headers }), req, res, false);
return;
}
cancelResponseBody(source);
res.writeHead(404);
res.end("Image not found");
return;
Expand Down Expand Up @@ -1508,6 +1549,7 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
server.listen(port, host, () => {
const addr = server.address();
const actualPort = typeof addr === "object" && addr ? addr.port : port;
listeningPort = actualPort;
if (!silent) logProdServerStarted(host, actualPort, purpose);
resolve();
});
Expand Down Expand Up @@ -1630,6 +1672,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {

// Build the static file metadata cache at startup (same as App Router).
const staticCache = await StaticFileCache.create(clientDir);
let listeningPort = port;

const handleRequest = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
const rawUrl = req.url ?? "/";
Expand Down Expand Up @@ -1737,35 +1780,39 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
res.end("Bad Request");
return;
}
// Block SVG and other unsafe content types.
// SVG is only allowed when dangerouslyAllowSVG is enabled.
const ext = path.extname(params.imageUrl).toLowerCase();
const ct = CONTENT_TYPES[ext] ?? "application/octet-stream";
if (!isSafeImageContentType(ct, pagesImageConfig?.dangerouslyAllowSVG)) {
res.writeHead(400);
res.end("The requested resource is not an allowed image type");
return;
}
const imageSecurityHeaders: Record<string, string> = {
"Content-Security-Policy":
pagesImageConfig?.contentSecurityPolicy ?? IMAGE_CONTENT_SECURITY_POLICY,
"X-Content-Type-Options": "nosniff",
"Content-Disposition":
pagesImageConfig?.contentDispositionType === "attachment" ? "attachment" : "inline",
};
if (
await tryServeStatic(
req,
res,
clientDir,
params.imageUrl,
false,
staticCache,
imageSecurityHeaders,
)
) {
const optimizerRequest = nodeToWebRequest(req, rawUrl, prerenderSecret);
const sourceRequest = createInternalImageRequest(params.imageUrl, optimizerRequest, basePath);
if (!sourceRequest) {
res.writeHead(400);
res.end("Bad Request");
return;
}
const source = await fetchInternalImageSource(sourceRequest, host, listeningPort);
if (source.ok && source.body) {
if (
!isSafeImageContentType(
source.headers.get("Content-Type"),
pagesImageConfig?.dangerouslyAllowSVG,
)
) {
cancelResponseBody(source);
res.writeHead(400);
res.end("The requested resource is not an allowed image type");
return;
}
const headers = imageSourceResponseHeaders(source);
for (const [name, value] of Object.entries(imageSecurityHeaders)) headers.set(name, value);
await sendWebResponse(new Response(source.body, { status: 200, headers }), req, res, false);
return;
}
cancelResponseBody(source);
res.writeHead(404);
res.end("Image not found");
return;
Expand Down Expand Up @@ -1977,6 +2024,7 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
server.listen(port, host, () => {
const addr = server.address();
const actualPort = typeof addr === "object" && addr ? addr.port : port;
listeningPort = actualPort;
if (!silent) logProdServerStarted(host, actualPort, purpose);
resolve();
});
Expand All @@ -2000,5 +2048,6 @@ export {
nodeToWebRequest,
mergeResponseHeaders,
mergeWebResponse,
internalImageOrigin,
tryServeStatic,
};
Loading
Loading