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
5 changes: 5 additions & 0 deletions .changeset/mean-sloths-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": patch
---

Add support for images CSP, disposition, and allow SVG
17 changes: 15 additions & 2 deletions packages/cloudflare/src/cli/build/open-next/compile-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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__,
},
});
}
100 changes: 98 additions & 2 deletions packages/cloudflare/src/cli/templates/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 */
2 changes: 1 addition & 1 deletion packages/cloudflare/src/cli/templates/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down