diff --git a/.changeset/mean-sloths-sit.md b/.changeset/mean-sloths-sit.md new file mode 100644 index 00000000..31ef74fc --- /dev/null +++ b/.changeset/mean-sloths-sit.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Add support for images CSP, disposition, and allow SVG diff --git a/packages/cloudflare/src/cli/build/open-next/compile-images.ts b/packages/cloudflare/src/cli/build/open-next/compile-images.ts index 782bcc70..6d4991fb 100644 --- a/packages/cloudflare/src/cli/build/open-next/compile-images.ts +++ b/packages/cloudflare/src/cli/build/open-next/compile-images.ts @@ -18,6 +18,16 @@ export async function compileImages(options: BuildOptions) { ? JSON.parse(fs.readFileSync(imagesManifestPath, { encoding: "utf-8" })) : {}; + const __IMAGES_REMOTE_PATTERNS__ = JSON.stringify(imagesManifest?.images?.remotePatterns ?? []); + const __IMAGES_LOCAL_PATTERNS__ = JSON.stringify(imagesManifest?.images?.localPatterns ?? []); + const __IMAGES_ALLOW_SVG__ = JSON.stringify(Boolean(imagesManifest?.images?.dangerouslyAllowSVG)); + const __IMAGES_CONTENT_SECURITY_POLICY__ = JSON.stringify( + imagesManifest?.images?.contentSecurityPolicy ?? "script-src 'none'; frame-src 'none'; sandbox;" + ); + const __IMAGES_CONTENT_DISPOSITION__ = JSON.stringify( + imagesManifest?.images?.contentDispositionType ?? "attachment" + ); + await build({ entryPoints: [imagesPath], outdir: path.join(options.outputDir, "cloudflare"), @@ -27,8 +37,11 @@ export async function compileImages(options: BuildOptions) { target: "esnext", platform: "node", define: { - __IMAGES_REMOTE_PATTERNS__: JSON.stringify(imagesManifest?.images?.remotePatterns ?? []), - __IMAGES_LOCAL_PATTERNS__: JSON.stringify(imagesManifest?.images?.localPatterns ?? []), + __IMAGES_REMOTE_PATTERNS__, + __IMAGES_LOCAL_PATTERNS__, + __IMAGES_ALLOW_SVG__, + __IMAGES_CONTENT_SECURITY_POLICY__, + __IMAGES_CONTENT_DISPOSITION__, }, }); } diff --git a/packages/cloudflare/src/cli/templates/images.ts b/packages/cloudflare/src/cli/templates/images.ts index 2900fd5a..c1b1c96e 100644 --- a/packages/cloudflare/src/cli/templates/images.ts +++ b/packages/cloudflare/src/cli/templates/images.ts @@ -19,7 +19,7 @@ export type LocalPattern = { * Local images (starting with a '/' as fetched using the passed fetcher). * Remote images should match the configured remote patterns or a 404 response is returned. */ -export function fetchImage(fetcher: Fetcher | undefined, imageUrl: string) { +export async function fetchImage(fetcher: Fetcher | undefined, imageUrl: string, ctx: ExecutionContext) { // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L208 if (!imageUrl || imageUrl.length > 3072 || imageUrl.startsWith("//")) { return getUrlErrorResponse(); @@ -69,7 +69,45 @@ export function fetchImage(fetcher: Fetcher | undefined, imageUrl: string) { return getUrlErrorResponse(); } - return fetch(imageUrl, { cf: { cacheEverything: true } }); + const imgResponse = await fetch(imageUrl, { cf: { cacheEverything: true } }); + + if (!imgResponse.body) { + return imgResponse; + } + + const buffer = new ArrayBuffer(32); + + try { + let contentType: string | undefined; + // body1 is eventually used for the response + // body2 is used to detect the content type + const [body1, body2] = imgResponse.body.tee(); + const reader = body2.getReader({ mode: "byob" }); + const { value } = await reader.read(new Uint8Array(buffer)); + // Release resources by calling `reader.cancel()` + // `ctx.waitUntil` keeps the runtime running until the promise settles without having to wait here. + ctx.waitUntil(reader.cancel()); + + if (value) { + contentType = detectContentType(value); + } + + if (contentType && !(contentType === SVG && !__IMAGES_ALLOW_SVG__)) { + const headers = new Headers(imgResponse.headers); + headers.set("content-type", contentType); + headers.set("content-disposition", __IMAGES_CONTENT_DISPOSITION__); + headers.set("content-security-policy", __IMAGES_CONTENT_SECURITY_POLICY__); + return new Response(body1, { ...imgResponse, headers }); + } + + return new Response('"url" parameter is valid but image type is not allowed', { + status: 400, + }); + } catch { + return new Response('"url" parameter is valid but upstream response is invalid', { + status: 400, + }); + } } export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean { @@ -113,9 +151,67 @@ function getUrlErrorResponse() { return new Response(`"url" parameter is not allowed`, { status: 400 }); } +const AVIF = "image/avif"; +const WEBP = "image/webp"; +const PNG = "image/png"; +const JPEG = "image/jpeg"; +const GIF = "image/gif"; +const SVG = "image/svg+xml"; +const ICO = "image/x-icon"; +const ICNS = "image/x-icns"; +const TIFF = "image/tiff"; +const BMP = "image/bmp"; + +/** + * Detects the content type by looking at the first few bytes of a file + * + * Based on https://github.com/vercel/next.js/blob/72c9635/packages/next/src/server/image-optimizer.ts#L155 + * + * @param buffer The image bytes + * @returns a content type of undefined for unsupported content + */ +export function detectContentType(buffer: Uint8Array) { + if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) { + return JPEG; + } + if ([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => buffer[i] === b)) { + return PNG; + } + if ([0x47, 0x49, 0x46, 0x38].every((b, i) => buffer[i] === b)) { + return GIF; + } + if ([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50].every((b, i) => !b || buffer[i] === b)) { + return WEBP; + } + if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) { + return SVG; + } + if ([0x3c, 0x73, 0x76, 0x67].every((b, i) => buffer[i] === b)) { + return SVG; + } + if ([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every((b, i) => !b || buffer[i] === b)) { + return AVIF; + } + if ([0x00, 0x00, 0x01, 0x00].every((b, i) => buffer[i] === b)) { + return ICO; + } + if ([0x69, 0x63, 0x6e, 0x73].every((b, i) => buffer[i] === b)) { + return ICNS; + } + if ([0x49, 0x49, 0x2a, 0x00].every((b, i) => buffer[i] === b)) { + return TIFF; + } + if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) { + return BMP; + } +} + /* eslint-disable no-var */ declare global { var __IMAGES_REMOTE_PATTERNS__: RemotePattern[]; var __IMAGES_LOCAL_PATTERNS__: LocalPattern[]; + var __IMAGES_ALLOW_SVG__: boolean; + var __IMAGES_CONTENT_SECURITY_POLICY__: string; + var __IMAGES_CONTENT_DISPOSITION__: string; } /* eslint-enable no-var */ diff --git a/packages/cloudflare/src/cli/templates/worker.ts b/packages/cloudflare/src/cli/templates/worker.ts index a5ca1cd0..e44be971 100644 --- a/packages/cloudflare/src/cli/templates/worker.ts +++ b/packages/cloudflare/src/cli/templates/worker.ts @@ -41,7 +41,7 @@ export default { // Fallback for the Next default image loader. if (url.pathname === `${globalThis.__NEXT_BASE_PATH__}/_next/image`) { const imageUrl = url.searchParams.get("url") ?? ""; - return fetchImage(env.ASSETS, imageUrl); + return await fetchImage(env.ASSETS, imageUrl, ctx); } // - `Request`s are handled by the Next server