Skip to content

Commit d3ee433

Browse files
vicbsommeeeer
andcommitted
add support for images CSP, disposition, and allow SVG
Co-authored-by: Magnus Dahl Eide <[email protected]>
1 parent 38fb247 commit d3ee433

File tree

4 files changed

+117
-5
lines changed

4 files changed

+117
-5
lines changed

.changeset/mean-sloths-sit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": patch
3+
---
4+
5+
Add support for images CSP, disposition, and allow SVG

packages/cloudflare/src/cli/build/open-next/compile-images.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ export async function compileImages(options: BuildOptions) {
1818
? JSON.parse(fs.readFileSync(imagesManifestPath, { encoding: "utf-8" }))
1919
: {};
2020

21+
const __IMAGES_REMOTE_PATTERNS__ = JSON.stringify(imagesManifest?.images?.remotePatterns ?? []);
22+
const __IMAGES_LOCAL_PATTERNS__ = JSON.stringify(imagesManifest?.images?.localPatterns ?? []);
23+
const __IMAGES_ALLOW_SVG__ = JSON.stringify(Boolean(imagesManifest?.images?.dangerouslyAllowSVG));
24+
const __IMAGES_CONTENT_SECURITY_POLICY__ = JSON.stringify(
25+
imagesManifest?.images?.contentSecurityPolicy ?? "script-src 'none'; frame-src 'none'; sandbox;"
26+
);
27+
const __IMAGES_CONTENT_DISPOSITION__ = JSON.stringify(
28+
imagesManifest?.images?.contentDispositionType ?? "attachment"
29+
);
30+
2131
await build({
2232
entryPoints: [imagesPath],
2333
outdir: path.join(options.outputDir, "cloudflare"),
@@ -27,8 +37,11 @@ export async function compileImages(options: BuildOptions) {
2737
target: "esnext",
2838
platform: "node",
2939
define: {
30-
__IMAGES_REMOTE_PATTERNS__: JSON.stringify(imagesManifest?.images?.remotePatterns ?? []),
31-
__IMAGES_LOCAL_PATTERNS__: JSON.stringify(imagesManifest?.images?.localPatterns ?? []),
40+
__IMAGES_REMOTE_PATTERNS__,
41+
__IMAGES_LOCAL_PATTERNS__,
42+
__IMAGES_ALLOW_SVG__,
43+
__IMAGES_CONTENT_SECURITY_POLICY__,
44+
__IMAGES_CONTENT_DISPOSITION__,
3245
},
3346
});
3447
}

packages/cloudflare/src/cli/templates/images.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export type LocalPattern = {
1919
* Local images (starting with a '/' as fetched using the passed fetcher).
2020
* Remote images should match the configured remote patterns or a 404 response is returned.
2121
*/
22-
export function fetchImage(fetcher: Fetcher | undefined, imageUrl: string) {
22+
export async function fetchImage(fetcher: Fetcher | undefined, imageUrl: string, ctx: ExecutionContext) {
2323
// https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L208
2424
if (!imageUrl || imageUrl.length > 3072 || imageUrl.startsWith("//")) {
2525
return getUrlErrorResponse();
@@ -69,7 +69,43 @@ export function fetchImage(fetcher: Fetcher | undefined, imageUrl: string) {
6969
return getUrlErrorResponse();
7070
}
7171

72-
return fetch(imageUrl, { cf: { cacheEverything: true } });
72+
const imgResponse = await fetch(imageUrl, { cf: { cacheEverything: true } });
73+
74+
if (!imgResponse.body) {
75+
return imgResponse;
76+
}
77+
78+
const buffer = new ArrayBuffer(32);
79+
80+
try {
81+
let contentType: string | undefined;
82+
// body1 is eventually used for the response
83+
// body2 is used to detect the content type
84+
const [body1, body2] = imgResponse.body.tee();
85+
const reader = body2.getReader({ mode: "byob" });
86+
const { value } = await reader.read(new Uint8Array(buffer));
87+
ctx.waitUntil(reader.cancel());
88+
89+
if (value) {
90+
contentType = detectContentType(value);
91+
}
92+
93+
if (contentType && !(contentType === SVG && !__IMAGES_ALLOW_SVG__)) {
94+
const headers = new Headers(imgResponse.headers);
95+
headers.set("content-type", contentType);
96+
headers.set("content-disposition", __IMAGES_CONTENT_DISPOSITION__);
97+
headers.set("content-security-policy", __IMAGES_CONTENT_SECURITY_POLICY__);
98+
return new Response(body1, { ...imgResponse, headers });
99+
}
100+
101+
return new Response('"url" parameter is valid but image type is not allowed', {
102+
status: 400,
103+
});
104+
} catch {
105+
return new Response('"url" parameter is valid but upstream response is invalid', {
106+
status: 400,
107+
});
108+
}
73109
}
74110

75111
export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean {
@@ -113,9 +149,67 @@ function getUrlErrorResponse() {
113149
return new Response(`"url" parameter is not allowed`, { status: 400 });
114150
}
115151

152+
const AVIF = "image/avif";
153+
const WEBP = "image/webp";
154+
const PNG = "image/png";
155+
const JPEG = "image/jpeg";
156+
const GIF = "image/gif";
157+
const SVG = "image/svg+xml";
158+
const ICO = "image/x-icon";
159+
const ICNS = "image/x-icns";
160+
const TIFF = "image/tiff";
161+
const BMP = "image/bmp";
162+
163+
/**
164+
* Detects the content type by looking at the first few bytes of a file
165+
*
166+
* Based on https://github.com/vercel/next.js/blob/72c9635/packages/next/src/server/image-optimizer.ts#L155
167+
*
168+
* @param buffer The image bytes
169+
* @returns a content type of undefined for unsupported content
170+
*/
171+
export function detectContentType(buffer: Uint8Array) {
172+
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
173+
return JPEG;
174+
}
175+
if ([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((b, i) => buffer[i] === b)) {
176+
return PNG;
177+
}
178+
if ([0x47, 0x49, 0x46, 0x38].every((b, i) => buffer[i] === b)) {
179+
return GIF;
180+
}
181+
if ([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50].every((b, i) => !b || buffer[i] === b)) {
182+
return WEBP;
183+
}
184+
if ([0x3c, 0x3f, 0x78, 0x6d, 0x6c].every((b, i) => buffer[i] === b)) {
185+
return SVG;
186+
}
187+
if ([0x3c, 0x73, 0x76, 0x67].every((b, i) => buffer[i] === b)) {
188+
return SVG;
189+
}
190+
if ([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66].every((b, i) => !b || buffer[i] === b)) {
191+
return AVIF;
192+
}
193+
if ([0x00, 0x00, 0x01, 0x00].every((b, i) => buffer[i] === b)) {
194+
return ICO;
195+
}
196+
if ([0x69, 0x63, 0x6e, 0x73].every((b, i) => buffer[i] === b)) {
197+
return ICNS;
198+
}
199+
if ([0x49, 0x49, 0x2a, 0x00].every((b, i) => buffer[i] === b)) {
200+
return TIFF;
201+
}
202+
if ([0x42, 0x4d].every((b, i) => buffer[i] === b)) {
203+
return BMP;
204+
}
205+
}
206+
116207
/* eslint-disable no-var */
117208
declare global {
118209
var __IMAGES_REMOTE_PATTERNS__: RemotePattern[];
119210
var __IMAGES_LOCAL_PATTERNS__: LocalPattern[];
211+
var __IMAGES_ALLOW_SVG__: boolean;
212+
var __IMAGES_CONTENT_SECURITY_POLICY__: string;
213+
var __IMAGES_CONTENT_DISPOSITION__: string;
120214
}
121215
/* eslint-enable no-var */

packages/cloudflare/src/cli/templates/worker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export default {
4141
// Fallback for the Next default image loader.
4242
if (url.pathname === `${globalThis.__NEXT_BASE_PATH__}/_next/image`) {
4343
const imageUrl = url.searchParams.get("url") ?? "";
44-
return fetchImage(env.ASSETS, imageUrl);
44+
return await fetchImage(env.ASSETS, imageUrl, ctx);
4545
}
4646

4747
// - `Request`s are handled by the Next server

0 commit comments

Comments
 (0)