Skip to content

Commit 36119c0

Browse files
authored
Enforce remotePatterns when fetching external images (#727)
1 parent eb44836 commit 36119c0

File tree

6 files changed

+419
-40
lines changed

6 files changed

+419
-40
lines changed

.changeset/plain-beds-win.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@opennextjs/cloudflare": minor
3+
---
4+
5+
Enforce remotePatterns when fetching external images

packages/cloudflare/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
"@tsconfig/strictest": "catalog:",
6565
"@types/mock-fs": "catalog:",
6666
"@types/node": "catalog:",
67+
"@types/picomatch": "^4.0.0",
6768
"diff": "^8.0.2",
6869
"esbuild": "catalog:",
6970
"eslint": "catalog:",
@@ -73,6 +74,7 @@
7374
"globals": "catalog:",
7475
"mock-fs": "catalog:",
7576
"next": "catalog:",
77+
"picomatch": "^4.0.2",
7678
"rimraf": "catalog:",
7779
"typescript": "catalog:",
7880
"typescript-eslint": "catalog:",

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
12
import path from "node:path";
23
import { fileURLToPath } from "node:url";
34

45
import { loadConfig } from "@opennextjs/aws/adapters/config/util.js";
56
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
67
import { build } from "esbuild";
8+
import pm from "picomatch";
79

810
/**
911
* Compiles the initialization code for the workerd runtime
@@ -16,6 +18,27 @@ export async function compileInit(options: BuildOptions) {
1618
const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
1719
const basePath = nextConfig.basePath ?? "";
1820

21+
// https://github.com/vercel/next.js/blob/d76f0b13/packages/next/src/build/index.ts#L573
22+
const nextRemotePatterns = nextConfig.images?.remotePatterns ?? [];
23+
24+
const remotePatterns = nextRemotePatterns.map((p) => ({
25+
protocol: p.protocol,
26+
hostname: p.hostname ? pm.makeRe(p.hostname).source : undefined,
27+
port: p.port,
28+
pathname: pm.makeRe(p.pathname ?? "**", { dot: true }).source,
29+
// search is canary only as of June 2025
30+
search: (p as any).search,
31+
}));
32+
33+
// Local patterns are only in canary as of June 2025
34+
const nextLocalPatterns = (nextConfig.images as any)?.localPatterns ?? [];
35+
36+
// https://github.com/vercel/next.js/blob/d76f0b13/packages/next/src/build/index.ts#L573
37+
const localPatterns = nextLocalPatterns.map((p: any) => ({
38+
pathname: pm.makeRe(p.pathname ?? "**", { dot: true }).source,
39+
search: p.search,
40+
}));
41+
1942
await build({
2043
entryPoints: [initPath],
2144
outdir: path.join(options.outputDir, "cloudflare"),
@@ -27,6 +50,8 @@ export async function compileInit(options: BuildOptions) {
2750
define: {
2851
__BUILD_TIMESTAMP_MS__: JSON.stringify(Date.now()),
2952
__NEXT_BASE_PATH__: JSON.stringify(basePath),
53+
__IMAGES_REMOTE_PATTERNS__: JSON.stringify(remotePatterns),
54+
__IMAGES_LOCAL_PATTERNS__: JSON.stringify(localPatterns),
3055
},
3156
});
3257
}

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

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,109 @@ function populateProcessEnv(url: URL, env: CloudflareEnv) {
140140
process.env.__NEXT_PRIVATE_ORIGIN = url.origin;
141141
}
142142

143+
export type RemotePattern = {
144+
protocol?: "http" | "https";
145+
hostname: string;
146+
port?: string;
147+
pathname: string;
148+
search?: string;
149+
};
150+
151+
const imgRemotePatterns = __IMAGES_REMOTE_PATTERNS__;
152+
153+
/**
154+
* Fetches an images.
155+
*
156+
* Local images (starting with a '/' as fetched using the passed fetcher).
157+
* Remote images should match the configured remote patterns or a 404 response is returned.
158+
*/
159+
export function fetchImage(fetcher: Fetcher | undefined, url: string) {
160+
// https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L208
161+
if (!url || url.length > 3072 || url.startsWith("//")) {
162+
return new Response("Not Found", { status: 404 });
163+
}
164+
165+
// Local
166+
if (url.startsWith("/")) {
167+
if (/\/_next\/image($|\/)/.test(decodeURIComponent(parseUrl(url)?.pathname ?? ""))) {
168+
return new Response("Not Found", { status: 404 });
169+
}
170+
171+
return fetcher?.fetch(`http://assets.local${url}`);
172+
}
173+
174+
// Remote
175+
let hrefParsed: URL;
176+
try {
177+
hrefParsed = new URL(url);
178+
} catch {
179+
return new Response("Not Found", { status: 404 });
180+
}
181+
182+
if (!["http:", "https:"].includes(hrefParsed.protocol)) {
183+
return new Response("Not Found", { status: 404 });
184+
}
185+
186+
if (!imgRemotePatterns.some((p: RemotePattern) => matchRemotePattern(p, hrefParsed))) {
187+
return new Response("Not Found", { status: 404 });
188+
}
189+
190+
return fetch(url, { cf: { cacheEverything: true } });
191+
}
192+
193+
export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean {
194+
// https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/shared/lib/match-remote-pattern.ts
195+
if (pattern.protocol !== undefined) {
196+
if (pattern.protocol.replace(/:$/, "") !== url.protocol.replace(/:$/, "")) {
197+
return false;
198+
}
199+
}
200+
if (pattern.port !== undefined) {
201+
if (pattern.port !== url.port) {
202+
return false;
203+
}
204+
}
205+
206+
if (pattern.hostname === undefined) {
207+
throw new Error(`Pattern should define hostname but found\n${JSON.stringify(pattern)}`);
208+
} else {
209+
if (!new RegExp(pattern.hostname).test(url.hostname)) {
210+
return false;
211+
}
212+
}
213+
214+
if (pattern.search !== undefined) {
215+
if (pattern.search !== url.search) {
216+
return false;
217+
}
218+
}
219+
220+
// Should be the same as writeImagesManifest()
221+
if (!new RegExp(pattern.pathname).test(url.pathname)) {
222+
return false;
223+
}
224+
225+
return true;
226+
}
227+
228+
function parseUrl(url: string): URL | undefined {
229+
let parsed: URL | undefined = undefined;
230+
try {
231+
parsed = new URL(url, "http://n");
232+
} catch {
233+
// empty
234+
}
235+
return parsed;
236+
}
237+
143238
/* eslint-disable no-var */
144239
declare global {
145240
// Build timestamp
146241
var __BUILD_TIMESTAMP_MS__: number;
147242
// Next basePath
148243
var __NEXT_BASE_PATH__: string;
244+
// Images patterns
245+
var __IMAGES_REMOTE_PATTERNS__: RemotePattern[];
246+
var __IMAGES_LOCAL_PATTERNS__: unknown[];
149247
}
150248
/* eslint-enable no-var */

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
//@ts-expect-error: Will be resolved by wrangler build
2-
import { runWithCloudflareRequestContext } from "./cloudflare/init.js";
2+
import { fetchImage, runWithCloudflareRequestContext } from "./cloudflare/init.js";
33
// @ts-expect-error: Will be resolved by wrangler build
44
import { handler as middlewareHandler } from "./middleware/handler.mjs";
55

@@ -31,9 +31,7 @@ export default {
3131
// Fallback for the Next default image loader.
3232
if (url.pathname === `${globalThis.__NEXT_BASE_PATH__}/_next/image`) {
3333
const imageUrl = url.searchParams.get("url") ?? "";
34-
return imageUrl.startsWith("/")
35-
? env.ASSETS?.fetch(`http://assets.local${imageUrl}`)
36-
: fetch(imageUrl, { cf: { cacheEverything: true } });
34+
return fetchImage(env.ASSETS, imageUrl);
3735
}
3836

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

0 commit comments

Comments
 (0)