Skip to content
Merged
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
6 changes: 6 additions & 0 deletions packages/vinext/src/config/next-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export interface NextConfig {
deviceSizes?: number[];
/** Allowed image sizes for fixed-width images. Defaults to Next.js defaults: [16, 32, 48, 64, 96, 128, 256, 384] */
imageSizes?: number[];
/** Allow SVG images through the image optimization endpoint. SVG can contain scripts, so only enable if you trust all image sources. */
dangerouslyAllowSVG?: boolean;
/** Content-Disposition header for image responses. Defaults to "inline". */
contentDispositionType?: "inline" | "attachment";
/** Content-Security-Policy header for image responses. Defaults to "script-src 'none'; frame-src 'none'; sandbox;" */
contentSecurityPolicy?: string;
};
/** Build output mode: 'export' for full static export, 'standalone' for single server */
output?: "export" | "standalone";
Expand Down
15 changes: 14 additions & 1 deletion packages/vinext/src/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ export function generateAppRouterWorkerEntry(): string {
* directly in wrangler.jsonc: "main": "vinext/server/app-router-entry"
*/
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import type { ImageConfig } from "vinext/server/image-optimization";
import handler from "vinext/server/app-router-entry";

interface Env {
Expand All @@ -405,6 +406,12 @@ interface Env {
};
}

// Image security config. SVG sources with .svg extension auto-skip the
// optimization endpoint on the client side (served directly, no proxy).
// To route SVGs through the optimizer (with security headers), set
// dangerouslyAllowSVG: true in next.config.js and uncomment below:
// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
Expand Down Expand Up @@ -437,6 +444,7 @@ export function generatePagesRouterWorkerEntry(): string {
* Edit freely or delete to regenerate on next deploy.
*/
import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
import type { ImageConfig } from "vinext/server/image-optimization";
import {
matchRedirect,
matchRewrite,
Expand Down Expand Up @@ -466,6 +474,11 @@ const trailingSlash: boolean = vinextConfig?.trailingSlash ?? false;
const configRedirects = vinextConfig?.redirects ?? [];
const configRewrites = vinextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] };
const configHeaders = vinextConfig?.headers ?? [];
const imageConfig: ImageConfig | undefined = vinextConfig?.images ? {
dangerouslyAllowSVG: vinextConfig.images.dangerouslyAllowSVG,
contentDispositionType: vinextConfig.images.contentDispositionType,
contentSecurityPolicy: vinextConfig.images.contentSecurityPolicy,
} : undefined;

export default {
async fetch(request: Request, env: Env): Promise<Response> {
Expand Down Expand Up @@ -498,7 +511,7 @@ export default {
const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
return result.response();
},
}, allowedWidths);
}, allowedWidths, imageConfig);
}

// ── 2. Trailing slash normalization ────────────────────────────
Expand Down
43 changes: 43 additions & 0 deletions packages/vinext/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,13 @@ export default function vinext(options: VinextOptions = {}): Plugin[] {
rewrites: nextConfig?.rewrites ?? { beforeFiles: [], afterFiles: [], fallback: [] },
headers: nextConfig?.headers ?? [],
i18n: nextConfig?.i18n ?? null,
images: {
deviceSizes: nextConfig?.images?.deviceSizes,
imageSizes: nextConfig?.images?.imageSizes,
dangerouslyAllowSVG: nextConfig?.images?.dangerouslyAllowSVG,
contentDispositionType: nextConfig?.images?.contentDispositionType,
contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy,
},
});

// Generate middleware code if middleware.ts exists
Expand Down Expand Up @@ -1740,6 +1747,11 @@ hydrate();
JSON.stringify(imageSizes),
);
}
// Expose dangerouslyAllowSVG flag for the image shim's auto-skip logic.
// When false (default), .svg sources bypass the optimization endpoint.
defines["process.env.__VINEXT_IMAGE_DANGEROUSLY_ALLOW_SVG"] = JSON.stringify(
String(nextConfig.images?.dangerouslyAllowSVG ?? false),
);
// Draft mode secret — generated once at build time so the
// __prerender_bypass cookie is consistent across all server
// instances (e.g. multiple Cloudflare Workers isolates).
Expand Down Expand Up @@ -3122,6 +3134,37 @@ hydrate();
},
},
},
// Write image config JSON for the App Router production server.
// The App Router RSC entry doesn't export vinextConfig (that's a Pages
// Router pattern), so we write a separate JSON file at build time that
// prod-server.ts reads at startup for SVG/security header config.
{
name: "vinext:image-config",
apply: "build",
enforce: "post",
writeBundle: {
sequential: true,
order: "post",
handler(options) {
const envName = this.environment?.name;
if (envName !== "rsc") return;

const outDir = options.dir;
if (!outDir) return;

const imageConfig = {
dangerouslyAllowSVG: nextConfig?.images?.dangerouslyAllowSVG,
contentDispositionType: nextConfig?.images?.contentDispositionType,
contentSecurityPolicy: nextConfig?.images?.contentSecurityPolicy,
};

fs.writeFileSync(
path.join(outDir, "image-config.json"),
JSON.stringify(imageConfig),
);
},
},
},
// Cloudflare Workers production build integration:
// After all environments are built, compute lazy chunks from the client
// build manifest and inject globals into the worker entry.
Expand Down
55 changes: 43 additions & 12 deletions packages/vinext/src/server/image-optimization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,26 @@
* Security: All image responses include Content-Security-Policy and
* X-Content-Type-Options headers to prevent XSS via SVG or Content-Type
* spoofing. SVG content is blocked by default (following Next.js behavior).
* When `dangerouslyAllowSVG` is enabled in next.config.js, SVGs are served
* as-is (no transformation) with security headers applied.
*/

/** The pathname that triggers image optimization. */
export const IMAGE_OPTIMIZATION_PATH = "/_vinext/image";

/**
* Image security configuration from next.config.js `images` section.
* Controls SVG handling and security headers for the image endpoint.
*/
export interface ImageConfig {
/** Allow SVG through the image optimization endpoint. Default: false. */
dangerouslyAllowSVG?: boolean;
/** Content-Disposition header value. Default: "inline". */
contentDispositionType?: "inline" | "attachment";
/** Content-Security-Policy header value. Default: "script-src 'none'; frame-src 'none'; sandbox;" */
contentSecurityPolicy?: string;
}

/**
* Next.js default device sizes and image sizes.
* These are the allowed widths for image optimization when no custom
Expand Down Expand Up @@ -130,24 +145,27 @@ const SAFE_IMAGE_CONTENT_TYPES = new Set([

/**
* Check if a Content-Type header value is a safe image type.
* Returns false for SVG, HTML, or any non-image type.
* Returns false for SVG (unless dangerouslyAllowSVG is true), HTML, or any non-image type.
*/
export function isSafeImageContentType(contentType: string | null): boolean {
export function isSafeImageContentType(contentType: string | null, dangerouslyAllowSVG = false): boolean {
if (!contentType) return false;
// Extract the media type, ignoring parameters (e.g., charset)
const mediaType = contentType.split(";")[0].trim().toLowerCase();
return SAFE_IMAGE_CONTENT_TYPES.has(mediaType);
if (SAFE_IMAGE_CONTENT_TYPES.has(mediaType)) return true;
if (dangerouslyAllowSVG && mediaType === "image/svg+xml") return true;
return false;
}

/**
* Apply security headers to an image optimization response.
* These headers are set on every response from the image endpoint,
* regardless of whether the image was transformed or served as-is.
* When an ImageConfig is provided, uses its values for CSP and Content-Disposition.
*/
function setImageSecurityHeaders(headers: Headers): void {
headers.set("Content-Security-Policy", IMAGE_CONTENT_SECURITY_POLICY);
function setImageSecurityHeaders(headers: Headers, config?: ImageConfig): void {
headers.set("Content-Security-Policy", config?.contentSecurityPolicy ?? IMAGE_CONTENT_SECURITY_POLICY);
headers.set("X-Content-Type-Options", "nosniff");
headers.set("Content-Disposition", "inline");
headers.set("Content-Disposition", config?.contentDispositionType ?? "inline");
}

/**
Expand Down Expand Up @@ -175,6 +193,7 @@ export async function handleImageOptimization(
request: Request,
handlers: ImageHandlers,
allowedWidths?: number[],
imageConfig?: ImageConfig,
): Promise<Response> {
const url = new URL(request.url);
const params = parseImageParams(url, allowedWidths);
Expand All @@ -195,13 +214,25 @@ export async function handleImageOptimization(
const format = negotiateImageFormat(request.headers.get("Accept"));

// Block unsafe Content-Types (e.g., SVG which can contain embedded scripts).
// Check the source Content-Type before any processing — if the source is
// an SVG or other non-image type, reject it regardless of transformation.
// Check the source Content-Type before any processing. SVG is only allowed
// when dangerouslyAllowSVG is explicitly enabled in next.config.js.
const sourceContentType = source.headers.get("Content-Type");
if (!isSafeImageContentType(sourceContentType)) {
if (!isSafeImageContentType(sourceContentType, imageConfig?.dangerouslyAllowSVG)) {
return new Response("The requested resource is not an allowed image type", { status: 400 });
}

// SVG passthrough: SVG is a vector format, so transformation (resize, format
// conversion) provides no benefit. Serve as-is with security headers.
// This matches Next.js behavior where SVG is a "bypass type".
const sourceMediaType = sourceContentType?.split(";")[0].trim().toLowerCase();
if (sourceMediaType === "image/svg+xml") {
const headers = new Headers(source.headers);
headers.set("Cache-Control", IMAGE_CACHE_CONTROL);
headers.set("Vary", "Accept");
setImageSecurityHeaders(headers, imageConfig);
return new Response(source.body, { status: 200, headers });
}

// Transform if handler provided, otherwise serve original
if (handlers.transformImage) {
try {
Expand All @@ -213,11 +244,11 @@ export async function handleImageOptimization(
const headers = new Headers(transformed.headers);
headers.set("Cache-Control", IMAGE_CACHE_CONTROL);
headers.set("Vary", "Accept");
setImageSecurityHeaders(headers);
setImageSecurityHeaders(headers, imageConfig);

// Verify the transformed response also has a safe Content-Type.
// A malicious or buggy transform handler could return HTML.
if (!isSafeImageContentType(headers.get("Content-Type"))) {
if (!isSafeImageContentType(headers.get("Content-Type"), imageConfig?.dangerouslyAllowSVG)) {
headers.set("Content-Type", format);
}

Expand All @@ -232,6 +263,6 @@ export async function handleImageOptimization(
const headers = new Headers(source.headers);
headers.set("Cache-Control", IMAGE_CACHE_CONTROL);
headers.set("Vary", "Accept");
setImageSecurityHeaders(headers);
setImageSecurityHeaders(headers, imageConfig);
return new Response(source.body, { status: 200, headers });
}
35 changes: 26 additions & 9 deletions packages/vinext/src/server/prod-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import path from "node:path";
import zlib from "node:zlib";
import { matchRedirect, matchRewrite, matchHeaders, requestContextFromRequest, isExternalUrl, proxyExternalRequest, sanitizeDestination } from "../config/config-matchers.js";
import type { RequestContext } from "../config/config-matchers.js";
import { IMAGE_OPTIMIZATION_PATH, IMAGE_CONTENT_SECURITY_POLICY, parseImageParams, isSafeImageContentType, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "./image-optimization.js";
import { IMAGE_OPTIMIZATION_PATH, IMAGE_CONTENT_SECURITY_POLICY, parseImageParams, isSafeImageContentType, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, type ImageConfig } from "./image-optimization.js";
import { normalizePath } from "./normalize-path.js";
import { computeLazyChunks } from "../index.js";

Expand Down Expand Up @@ -466,6 +466,16 @@ interface AppRouterServerOptions {
async function startAppRouterServer(options: AppRouterServerOptions) {
const { port, host, clientDir, rscEntryPath, compress } = options;

// Load image config written at build time by vinext:image-config plugin.
// This provides SVG/security header settings for the image optimization endpoint.
let imageConfig: ImageConfig | undefined;
const imageConfigPath = path.join(path.dirname(rscEntryPath), "image-config.json");
if (fs.existsSync(imageConfigPath)) {
try {
imageConfig = JSON.parse(fs.readFileSync(imageConfigPath, "utf-8"));
} catch { /* ignore parse errors */ }
}

// Import the RSC handler (use file:// URL for reliable dynamic import)
const rscModule = await import(pathToFileURL(rscEntryPath).href);
const rscHandler: (request: Request) => Promise<Response> = rscModule.default;
Expand Down Expand Up @@ -514,19 +524,19 @@ async function startAppRouterServer(options: AppRouterServerOptions) {
return;
}
// Block SVG and other unsafe content types by checking the file extension.
// This must happen before serving to prevent XSS via SVG passthrough.
// 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)) {
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": IMAGE_CONTENT_SECURITY_POLICY,
"Content-Security-Policy": imageConfig?.contentSecurityPolicy ?? IMAGE_CONTENT_SECURITY_POLICY,
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline",
"Content-Disposition": imageConfig?.contentDispositionType ?? "inline",
};
if (tryServeStatic(req, res, clientDir, params.imageUrl, false, imageSecurityHeaders)) {
return;
Expand Down Expand Up @@ -622,6 +632,12 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
...(vinextConfig?.images?.deviceSizes ?? DEFAULT_DEVICE_SIZES),
...(vinextConfig?.images?.imageSizes ?? DEFAULT_IMAGE_SIZES),
];
// Extract image security config for SVG handling and security headers
const pagesImageConfig: ImageConfig | undefined = vinextConfig?.images ? {
dangerouslyAllowSVG: vinextConfig.images.dangerouslyAllowSVG,
contentDispositionType: vinextConfig.images.contentDispositionType,
contentSecurityPolicy: vinextConfig.images.contentSecurityPolicy,
} : undefined;

const server = createServer(async (req, res) => {
const rawUrl = req.url ?? "/";
Expand Down Expand Up @@ -674,18 +690,19 @@ async function startPagesRouterServer(options: PagesRouterServerOptions) {
res.end("Bad Request");
return;
}
// Block SVG and other unsafe content types
// 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)) {
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": IMAGE_CONTENT_SECURITY_POLICY,
"Content-Security-Policy": pagesImageConfig?.contentSecurityPolicy ?? IMAGE_CONTENT_SECURITY_POLICY,
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline",
"Content-Disposition": pagesImageConfig?.contentDispositionType ?? "inline",
};
if (tryServeStatic(req, res, clientDir, params.imageUrl, false, imageSecurityHeaders)) {
return;
Expand Down
19 changes: 16 additions & 3 deletions packages/vinext/src/shims/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ const __imageDeviceSizes: number[] = (() => {
return [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
}
})();
/**
* Whether dangerouslyAllowSVG is enabled in next.config.js.
* When false (default), .svg sources auto-skip the optimization endpoint
* and are served directly, matching Next.js behavior.
* When true, .svg sources are routed through the optimizer (served as-is
* with security headers).
*/
const __dangerouslyAllowSVG = process.env.__VINEXT_IMAGE_DANGEROUSLY_ALLOW_SVG === "true";
/**
* Validate that a remote URL is allowed by the configured remote patterns.
* Returns true if the URL is allowed, false otherwise.
Expand Down Expand Up @@ -280,8 +288,11 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
// In production on Cloudflare Workers, this resizes and transcodes via
// the Images binding. In dev, it serves the original file as a passthrough.
// When `unoptimized` is true, bypass the endpoint entirely (Next.js compat).
// SVG sources auto-skip unless dangerouslyAllowSVG is enabled, matching
// Next.js behavior where .svg triggers unoptimized=true by default.
const imgQuality = quality ?? 75;
const skipOptimization = _unoptimized === true;
const isSvg = src.endsWith(".svg");
const skipOptimization = _unoptimized === true || (isSvg && !__dangerouslyAllowSVG);

// Build srcSet for responsive local images (common breakpoints).
// Each entry points to /_vinext/image with the appropriate width.
Expand Down Expand Up @@ -393,15 +404,17 @@ export function getImageProps(props: ImageProps): {

// For local images (no loader, not remote), route through optimization endpoint.
// When `unoptimized` is true, bypass the endpoint entirely (Next.js compat).
const skipOpt = _unoptimized === true || blockedInProd || !!loader || isRemoteUrl(resolvedSrc);
// SVG sources auto-skip unless dangerouslyAllowSVG is enabled.
const isSvg = resolvedSrc.endsWith(".svg");
const skipOpt = _unoptimized === true || (isSvg && !__dangerouslyAllowSVG) || blockedInProd || !!loader || isRemoteUrl(resolvedSrc);
const optimizedSrc = skipOpt
? resolvedSrc
: imgWidth
? imageOptimizationUrl(resolvedSrc, imgWidth, imgQuality)
: imageOptimizationUrl(resolvedSrc, RESPONSIVE_WIDTHS[0], imgQuality);

// Build srcSet for local images — each width points to /_vinext/image
const srcSet = imgWidth && !fill && !isRemoteUrl(resolvedSrc) && !loader && !_unoptimized
const srcSet = imgWidth && !fill && !isRemoteUrl(resolvedSrc) && !loader && !skipOpt
? generateSrcSet(resolvedSrc, imgWidth, imgQuality)
: undefined;

Expand Down
Loading
Loading