Skip to content

Commit bd448a8

Browse files
vicbsommeeeer
andauthored
�����������add support for images CSP, disposition, and allow SVG� (#776)
Co-authored-by: Magnus Dahl Eide <magnus@dahleide.com>
1 parent 38fb247 commit bd448a8

File tree

4 files changed

+119
-5
lines changed

4 files changed

+119
-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: 98 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,45 @@ 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+
// Release resources by calling `reader.cancel()`
88+
// `ctx.waitUntil` keeps the runtime running until the promise settles without having to wait here.
89+
ctx.waitUntil(reader.cancel());
90+
91+
if (value) {
92+
contentType = detectContentType(value);
93+
}
94+
95+
if (contentType && !(contentType === SVG && !__IMAGES_ALLOW_SVG__)) {
96+
const headers = new Headers(imgResponse.headers);
97+
headers.set("content-type", contentType);
98+
headers.set("content-disposition", __IMAGES_CONTENT_DISPOSITION__);
99+
headers.set("content-security-policy", __IMAGES_CONTENT_SECURITY_POLICY__);
100+
return new Response(body1, { ...imgResponse, headers });
101+
}
102+
103+
return new Response('"url" parameter is valid but image type is not allowed', {
104+
status: 400,
105+
});
106+
} catch {
107+
return new Response('"url" parameter is valid but upstream response is invalid', {
108+
status: 400,
109+
});
110+
}
73111
}
74112

75113
export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean {
@@ -113,9 +151,67 @@ function getUrlErrorResponse() {
113151
return new Response(`"url" parameter is not allowed`, { status: 400 });
114152
}
115153

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