Skip to content

Commit d803737

Browse files
[miniflare] Fix /cdn-cgi/* host validation incorrectly accepting subdomains of exact configured routes (#13912)
1 parent 59cd880 commit d803737

3 files changed

Lines changed: 182 additions & 18 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"miniflare": patch
3+
"wrangler": patch
4+
"@cloudflare/vite-plugin": patch
5+
---
6+
7+
Fix `/cdn-cgi/*` host validation incorrectly accepting subdomains of exact configured routes
8+
9+
Miniflare's `/cdn-cgi/*` host/origin validator was treating exact configured routes the same as wildcard configured routes, so a request whose `Host` or `Origin` hostname was a subdomain of an exact route (e.g. `sub.my-custom-site.com` for a `my-custom-site.com/*` route) was incorrectly accepted. Exact configured routes and the configured `upstream` hostname are now required to match the request hostname exactly. Subdomain matching is only applied to wildcard routes such as `*.example.com/*`. Localhost hostnames continue to be allowed as before.
10+
11+
This affects `wrangler dev` and local development through `@cloudflare/vite-plugin`, both of which use Miniflare under the hood.

packages/miniflare/src/workers/core/entry.worker.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -176,22 +176,30 @@ function validateCdnCgiRequest(
176176
return;
177177
}
178178

179-
// Build allowed hostnames set from localhost + user configured routes
180-
const allowedHostnames = new Set<string>();
179+
// Subdomain matching should only apply to wildcard routes (e.g.
180+
// "*.example.com/*"). Exact routes and the configured upstream hostname
181+
// must match the request hostname exactly.
182+
const exactHostnames = new Set<string>();
183+
const wildcardHostnames = new Set<string>();
181184
for (const route of routes) {
182185
// route.hostname has already had the leading "*" stripped by parseRoutes,
183-
// but may have a leading "." for wildcard routes (e.g., "*.example.com" -> ".example.com")
186+
// but wildcard routes still carry a leading "." (e.g., "*.example.com" -> ".example.com")
184187
const hostname = route.hostname.replace(/^\./, "");
185-
if (hostname) {
186-
allowedHostnames.add(hostname);
188+
if (!hostname) {
189+
continue;
190+
}
191+
if (route.allowHostnamePrefix) {
192+
wildcardHostnames.add(hostname);
193+
} else {
194+
exactHostnames.add(hostname);
187195
}
188196
}
189-
// Add upstream hostname if configured (e.g., from --host or --local-upstream)
197+
// Upstream is a single configured host, not a wildcard.
190198
if (upstreamUrl) {
191199
try {
192200
const upstreamHostname = new URL(upstreamUrl).hostname;
193201
if (upstreamHostname) {
194-
allowedHostnames.add(upstreamHostname);
202+
exactHostnames.add(upstreamHostname);
195203
}
196204
} catch {
197205
// Ignore invalid upstream URL
@@ -206,7 +214,7 @@ function validateCdnCgiRequest(
206214
} catch {
207215
throw new HttpError(403, "Invalid Host header");
208216
}
209-
if (!isHostnameAllowed(hostHostname, allowedHostnames)) {
217+
if (!isHostnameAllowed(hostHostname, exactHostnames, wildcardHostnames)) {
210218
throw new HttpError(403, "Invalid Host header");
211219
}
212220
}
@@ -219,34 +227,36 @@ function validateCdnCgiRequest(
219227
} catch {
220228
throw new HttpError(403, "Invalid Origin header");
221229
}
222-
if (!isHostnameAllowed(originHostname, allowedHostnames)) {
230+
if (!isHostnameAllowed(originHostname, exactHostnames, wildcardHostnames)) {
223231
throw new HttpError(403, "Invalid Origin header");
224232
}
225233
}
226234
}
227235

228236
/**
229-
* Check if a hostname is allowed.
230-
* - exactHostnames: must match exactly (localhost, upstream, non-wildcard routes)
231-
* - wildcardHostnames: allow subdomain matching (for wildcard routes like *.example.com)
237+
* Checks whether a hostname is allowed.
238+
* - exactHostnames: must match exactly (configured non-wildcard routes, upstream)
239+
* - wildcardHostnames: also match any subdomain (for wildcard routes like *.example.com)
240+
*
241+
* Localhost hostnames are always allowed.
232242
*/
233243
function isHostnameAllowed(
234244
hostname: string,
235-
allowedHostnames: Set<string>
245+
exactHostnames: Set<string>,
246+
wildcardHostnames: Set<string>
236247
): boolean {
237-
// Direct match against exact hostnames
238248
if (LOCALHOST_HOSTNAMES.includes(hostname)) {
239249
return true;
240250
}
241-
242-
// Check for subdomain matches only against wildcard hostnames
243-
for (const allowed of allowedHostnames) {
251+
if (exactHostnames.has(hostname)) {
252+
return true;
253+
}
254+
for (const allowed of wildcardHostnames) {
244255
// Match the base domain or any subdomain
245256
if (hostname === allowed || hostname.endsWith(`.${allowed}`)) {
246257
return true;
247258
}
248259
}
249-
250260
return false;
251261
}
252262

packages/miniflare/test/plugins/local-explorer/index.spec.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,48 @@ describe("Local Explorer works with custom routes", () => {
395395
expect(res.status).toBe(403);
396396
await res.arrayBuffer();
397397
});
398+
399+
// An exact configured route must not authorize subdomains of that route.
400+
// Subdomain matching should only apply to wildcard routes.
401+
test("blocks Origin that is a subdomain of an exact configured route", async ({
402+
expect,
403+
}) => {
404+
const res = await mf.dispatchFetch(`${BASE_URL}/storage/kv/namespaces`, {
405+
headers: { Origin: "https://sub.my-custom-site.com" },
406+
});
407+
expect(res.status).toBe(403);
408+
await res.arrayBuffer();
409+
});
410+
411+
test("blocks Host that is a subdomain of an exact configured route", async ({
412+
expect,
413+
}) => {
414+
const url = await mf.ready;
415+
const status = await new Promise<number>((resolve, reject) => {
416+
const req = http.get(
417+
`${url.origin}${CorePaths.EXPLORER}/api/storage/kv/namespaces`,
418+
{ setHost: false, headers: { Host: "sub.my-custom-site.com" } },
419+
(res) => {
420+
res.resume();
421+
resolve(res.statusCode ?? 0);
422+
}
423+
);
424+
req.on("error", reject);
425+
});
426+
expect(status).toBe(403);
427+
});
428+
429+
test("blocks look-alike hostname that ends with the exact route", async ({
430+
expect,
431+
}) => {
432+
// Guards against a naive endsWith() bypass: `evilmy-custom-site.com`
433+
// should NOT be accepted just because the suffix matches.
434+
const res = await mf.dispatchFetch(`${BASE_URL}/storage/kv/namespaces`, {
435+
headers: { Origin: "https://evilmy-custom-site.com" },
436+
});
437+
expect(res.status).toBe(403);
438+
await res.arrayBuffer();
439+
});
398440
});
399441

400442
describe("Local Explorer works with wildcard routes", () => {
@@ -433,6 +475,107 @@ describe("Local Explorer works with wildcard routes", () => {
433475
expect(res.status).toBe(200);
434476
await res.arrayBuffer();
435477
});
478+
479+
test("allows Origin that is a deep subdomain of a wildcard route", async ({
480+
expect,
481+
}) => {
482+
const res = await mf.dispatchFetch(`${BASE_URL}/storage/kv/namespaces`, {
483+
headers: { Origin: "https://a.b.example.com" },
484+
});
485+
expect(res.status).toBe(200);
486+
await res.arrayBuffer();
487+
});
488+
489+
test("blocks look-alike sibling hostname not under the wildcard route", async ({
490+
expect,
491+
}) => {
492+
// `notexample.com` ends with `example.com` but is not a subdomain of it.
493+
// Guards against a naive endsWith() match without a dot boundary.
494+
const res = await mf.dispatchFetch(`${BASE_URL}/storage/kv/namespaces`, {
495+
headers: { Origin: "https://notexample.com" },
496+
});
497+
expect(res.status).toBe(403);
498+
await res.arrayBuffer();
499+
});
500+
});
501+
502+
// The configured `upstream` hostname is a single host, not a wildcard.
503+
// Subdomains of the upstream hostname must not be authorized for /cdn-cgi/*
504+
// requests.
505+
describe("Local Explorer works with upstream", () => {
506+
let mf: Miniflare;
507+
508+
beforeAll(async () => {
509+
mf = new Miniflare({
510+
inspectorPort: 0,
511+
compatibilityDate: "2025-01-01",
512+
modules: true,
513+
script: `export default { fetch() { return new Response("user worker"); } }`,
514+
unsafeLocalExplorer: true,
515+
kvNamespaces: {
516+
TEST_KV: "test-kv-id",
517+
},
518+
upstream: "https://upstream-host.example/",
519+
});
520+
});
521+
522+
afterAll(async () => {
523+
await disposeWithRetry(mf);
524+
});
525+
526+
test("allows configured upstream host exactly", async ({ expect }) => {
527+
const url = await mf.ready;
528+
const status = await new Promise<number>((resolve, reject) => {
529+
const req = http.get(
530+
`${url.origin}${CorePaths.EXPLORER}/api/storage/kv/namespaces`,
531+
{ setHost: false, headers: { Host: "upstream-host.example" } },
532+
(res) => {
533+
res.resume();
534+
resolve(res.statusCode ?? 0);
535+
}
536+
);
537+
req.on("error", reject);
538+
});
539+
expect(status).toBe(200);
540+
});
541+
542+
test("blocks subdomain of configured upstream host (Host header)", async ({
543+
expect,
544+
}) => {
545+
const url = await mf.ready;
546+
const status = await new Promise<number>((resolve, reject) => {
547+
const req = http.get(
548+
`${url.origin}${CorePaths.EXPLORER}/api/storage/kv/namespaces`,
549+
{ setHost: false, headers: { Host: "sub.upstream-host.example" } },
550+
(res) => {
551+
res.resume();
552+
resolve(res.statusCode ?? 0);
553+
}
554+
);
555+
req.on("error", reject);
556+
});
557+
expect(status).toBe(403);
558+
});
559+
560+
test("blocks subdomain of configured upstream host (Origin header)", async ({
561+
expect,
562+
}) => {
563+
const res = await mf.dispatchFetch(`${BASE_URL}/storage/kv/namespaces`, {
564+
headers: { Origin: "https://sub.upstream-host.example" },
565+
});
566+
expect(res.status).toBe(403);
567+
await res.arrayBuffer();
568+
});
569+
570+
test("blocks unrelated external host even with upstream configured", async ({
571+
expect,
572+
}) => {
573+
const res = await mf.dispatchFetch(`${BASE_URL}/storage/kv/namespaces`, {
574+
headers: { Origin: "https://evil.com" },
575+
});
576+
expect(res.status).toBe(403);
577+
await res.arrayBuffer();
578+
});
436579
});
437580

438581
describe("Local Explorer /api/local/workers endpoint", () => {

0 commit comments

Comments
 (0)