Skip to content

Commit a69802c

Browse files
committed
Enforce remotePatterns when fetching external images
1 parent eb44836 commit a69802c

File tree

6 files changed

+532
-22
lines changed

6 files changed

+532
-22
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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
44
import { loadConfig } from "@opennextjs/aws/adapters/config/util.js";
55
import type { BuildOptions } from "@opennextjs/aws/build/helper.js";
66
import { build } from "esbuild";
7+
import pm from "picomatch";
78

89
/**
910
* Compiles the initialization code for the workerd runtime
@@ -16,6 +17,27 @@ export async function compileInit(options: BuildOptions) {
1617
const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
1718
const basePath = nextConfig.basePath ?? "";
1819

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

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,11 +140,103 @@ 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 = JSON.parse(__IMAGES_REMOTE_PATTERNS__);
152+
153+
export function fetchImage(fetcher: Fetcher | undefined, url: string) {
154+
// https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L208
155+
if (!url || url.length > 3072 || url.startsWith("//")) {
156+
return new Response("Not Found", { status: 404 });
157+
}
158+
159+
// Local
160+
if (url.startsWith("/")) {
161+
if (/\/_next\/image($|\/)/.test(decodeURIComponent(parseUrl(url)?.pathname ?? ""))) {
162+
return new Response("Not Found", { status: 404 });
163+
}
164+
165+
return fetcher?.fetch(`http://assets.local${url}`);
166+
}
167+
168+
// Remote
169+
let hrefParsed: URL;
170+
try {
171+
hrefParsed = new URL(url);
172+
} catch {
173+
return new Response("Not Found", { status: 404 });
174+
}
175+
176+
if (!["http:", "https:"].includes(hrefParsed.protocol)) {
177+
return new Response("Not Found", { status: 404 });
178+
}
179+
180+
if (!imgRemotePatterns.some((p: RemotePattern) => matchRemotePattern(p, hrefParsed))) {
181+
return new Response("Not Found", { status: 404 });
182+
}
183+
184+
fetch(url, { cf: { cacheEverything: true } });
185+
}
186+
187+
export function matchRemotePattern(pattern: RemotePattern, url: URL): boolean {
188+
// https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/shared/lib/match-remote-pattern.ts
189+
if (pattern.protocol !== undefined) {
190+
if (pattern.protocol.replace(/:$/, "") !== url.protocol.replace(/:$/, "")) {
191+
return false;
192+
}
193+
}
194+
if (pattern.port !== undefined) {
195+
if (pattern.port !== url.port) {
196+
return false;
197+
}
198+
}
199+
200+
if (pattern.hostname === undefined) {
201+
throw new Error(`Pattern should define hostname but found\n${JSON.stringify(pattern)}`);
202+
} else {
203+
if (new RegExp(pattern.hostname).test(url.hostname)) {
204+
return false;
205+
}
206+
}
207+
208+
if (pattern.search !== undefined) {
209+
if (pattern.search !== url.search) {
210+
return false;
211+
}
212+
}
213+
214+
// Should be the same as writeImagesManifest()
215+
if (new RegExp(pattern.pathname).test(url.pathname)) {
216+
return false;
217+
}
218+
219+
return true;
220+
}
221+
222+
function parseUrl(url: string): URL | undefined {
223+
let parsed: URL | undefined = undefined;
224+
try {
225+
parsed = new URL(url, "http://n");
226+
} catch {
227+
// empty
228+
}
229+
return parsed;
230+
}
231+
143232
/* eslint-disable no-var */
144233
declare global {
145234
// Build timestamp
146235
var __BUILD_TIMESTAMP_MS__: number;
147236
// Next basePath
148237
var __NEXT_BASE_PATH__: string;
238+
// Images patterns
239+
var __IMAGES_REMOTE_PATTERNS__: string;
240+
var __IMAGES_LOCAL_PATTERNS__: string;
149241
}
150242
/* 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)