Skip to content

Commit 63708a9

Browse files
authored
fix: validate Host/Origin headers in magic proxy and InspectorProxyWorker (#4550)
* Validate `Host` in Miniflare's proxy server * Validate `Host`/`Origin` headers in `InspectorProxyWorker`
1 parent 86c81ff commit 63708a9

File tree

6 files changed

+136
-8
lines changed

6 files changed

+136
-8
lines changed

.changeset/wise-seas-press.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"miniflare": patch
3+
"wrangler": patch
4+
---
5+
6+
fix: validate `Host` and `Orgin` headers where appropriate
7+
8+
`Host` and `Origin` headers are now checked when connecting to the inspector and Miniflare's magic proxy. If these don't match what's expected, the request will fail.

fixtures/dev-env/tests/index.test.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import assert from "node:assert";
2+
import events from "node:events";
3+
import timers from "node:timers/promises";
24
import getPort from "get-port";
35
import {
46
Miniflare,
@@ -186,14 +188,17 @@ function fakeReloadComplete(
186188
liveReload: config.dev?.liveReload,
187189
};
188190

189-
setTimeout(() => {
191+
const timeoutPromise = timers.setTimeout(delay).then(() => {
190192
devEnv.proxy.onReloadComplete({
191193
type: "reloadComplete",
192194
config,
193195
bundle: fakeBundle,
194196
proxyData,
195197
});
196-
}, delay);
198+
});
199+
// Add this promise to `fireAndForgetPromises`, ensuring it runs before we
200+
// start the next test
201+
fireAndForgetPromises.push(timeoutPromise);
197202

198203
return { config, mfOpts }; // convenience to allow calling and defining new config/mfOpts inline but also store the new objects
199204
}
@@ -283,6 +288,35 @@ describe("startDevWorker: ProxyController", () => {
283288
});
284289
});
285290

291+
test("InspectorProxyWorker rejects unauthorised requests", async () => {
292+
const run = await fakeStartUserWorker({
293+
script: `
294+
export default {
295+
fetch() {
296+
return new Response();
297+
}
298+
}
299+
`,
300+
});
301+
302+
// Check validates `Host` header
303+
ws = new WebSocket(
304+
`ws://${run.inspectorProxyWorkerUrl.host}/core:user:${run.config.name}`,
305+
{ setHost: false, headers: { Host: "example.com" } }
306+
);
307+
let openPromise = events.once(ws, "open");
308+
await expect(openPromise).rejects.toThrow("Unexpected server response");
309+
310+
// Check validates `Origin` header
311+
ws = new WebSocket(
312+
`ws://${run.inspectorProxyWorkerUrl.host}/core:user:${run.config.name}`,
313+
{ origin: "https://example.com" }
314+
);
315+
openPromise = events.once(ws, "open");
316+
await expect(openPromise).rejects.toThrow("Unexpected server response");
317+
ws.close();
318+
});
319+
286320
test("User worker exception", async () => {
287321
const consoleErrorSpy = vi.spyOn(console, "error");
288322

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323

2424
const ENCODER = new TextEncoder();
2525
const DECODER = new TextDecoder();
26+
const ALLOWED_HOSTNAMES = ["127.0.0.1", "[::1]", "localhost"];
2627

2728
const WORKERS_PLATFORM_IMPL: PlatformImpl<ReadableStream> = {
2829
Blob,
@@ -132,12 +133,27 @@ export class ProxyServer implements DurableObject {
132133
}
133134

134135
async #fetch(request: Request) {
136+
// Validate `Host` header
137+
const hostHeader = request.headers.get("Host");
138+
if (hostHeader == null) return new Response(null, { status: 400 });
139+
try {
140+
const host = new URL(`http://${hostHeader}`);
141+
if (!ALLOWED_HOSTNAMES.includes(host.hostname)) {
142+
return new Response(null, { status: 401 });
143+
}
144+
} catch {
145+
return new Response(null, { status: 400 });
146+
}
147+
135148
// Validate secret header to prevent unauthorised access to proxy
136149
const secretHex = request.headers.get(CoreHeaders.OP_SECRET);
137150
if (secretHex == null) return new Response(null, { status: 401 });
138151
const expectedSecret = this.env[CoreBindings.DATA_PROXY_SECRET];
139152
const secretBuffer = Buffer.from(secretHex, "hex");
140-
if (!crypto.subtle.timingSafeEqual(secretBuffer, expectedSecret)) {
153+
if (
154+
secretBuffer.byteLength !== expectedSecret.byteLength ||
155+
!crypto.subtle.timingSafeEqual(secretBuffer, expectedSecret)
156+
) {
141157
return new Response(null, { status: 401 });
142158
}
143159

packages/miniflare/test/plugins/core/proxy/client.spec.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "assert";
22
import { Blob } from "buffer";
3+
import http from "http";
34
import { text } from "stream/consumers";
45
import { ReadableStream } from "stream/web";
56
import util from "util";
@@ -185,7 +186,7 @@ test("ProxyClient: stack traces don't include internal implementation", async (t
185186

186187
const mf = new Miniflare({
187188
modules: true,
188-
script: `export class DurableObject {}
189+
script: `export class DurableObject {}
189190
export default {
190191
fetch() { return new Response(null, { status: 404 }); }
191192
}`,
@@ -270,9 +271,32 @@ test("ProxyClient: can `JSON.stringify()` proxies", async (t) => {
270271
test("ProxyServer: prevents unauthorised access", async (t) => {
271272
const mf = new Miniflare({ script: nullScript });
272273
t.teardown(() => mf.dispose());
273-
274274
const url = await mf.ready;
275-
const res = await fetch(url, { headers: { "MF-Op": "GET" } });
275+
276+
// Check validates `Host` header
277+
const statusPromise = new DeferredPromise<number>();
278+
const req = http.get(
279+
url,
280+
{ setHost: false, headers: { "MF-Op": "GET", Host: "localhost" } },
281+
(res) => statusPromise.resolve(res.statusCode ?? 0)
282+
);
283+
req.on("error", (error) => statusPromise.reject(error));
284+
t.is(await statusPromise, 401);
285+
286+
// Check validates `MF-Op-Secret` header
287+
let res = await fetch(url, {
288+
headers: { "MF-Op": "GET" }, // (missing)
289+
});
290+
t.is(res.status, 401);
291+
await res.arrayBuffer(); // (drain)
292+
res = await fetch(url, {
293+
headers: { "MF-Op": "GET", "MF-Op-Secret": "aaaa" }, // (too short)
294+
});
295+
t.is(res.status, 401);
296+
await res.arrayBuffer(); // (drain)
297+
res = await fetch(url, {
298+
headers: { "MF-Op": "GET", "MF-Op-Secret": "a".repeat(32) }, // (wrong)
299+
});
276300
t.is(res.status, 401);
277301
await res.arrayBuffer(); // (drain)
278302
});

packages/wrangler/src/api/startDevWorker/ProxyController.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ export class ProxyController extends EventEmitter {
6767
workers: [
6868
{
6969
name: "ProxyWorker",
70-
compatibilityFlags: ["nodejs_compat"],
70+
// `url_standard` required to parse IPv6 hostnames correctly
71+
compatibilityFlags: ["nodejs_compat", "url_standard"],
7172
modulesRoot: path.dirname(proxyWorkerPath),
7273
modules: [{ type: "ESModule", path: proxyWorkerPath }],
7374
durableObjects: {
@@ -96,7 +97,8 @@ export class ProxyController extends EventEmitter {
9697
},
9798
{
9899
name: "InspectorProxyWorker",
99-
compatibilityFlags: ["nodejs_compat"],
100+
// `url_standard` required to parse IPv6 hostnames correctly
101+
compatibilityFlags: ["nodejs_compat", "url_standard"],
100102
modulesRoot: path.dirname(inspectorProxyWorkerPath),
101103
modules: [{ type: "ESModule", path: inspectorProxyWorkerPath }],
102104
durableObjects: {

packages/wrangler/templates/startDevWorker/InspectorProxyWorker.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ import {
1919
urlFromParts,
2020
} from "../../src/api/startDevWorker/utils";
2121

22+
const ALLOWED_HOST_HOSTNAMES = ["127.0.0.1", "[::1]", "localhost"];
23+
const ALLOWED_ORIGIN_HOSTNAMES = [
24+
"devtools.devprod.cloudflare.dev",
25+
"cloudflare-devtools.pages.dev",
26+
/^[a-z0-9]+\.cloudflare-devtools\.pages\.dev$/,
27+
"127.0.0.1",
28+
"[::1]",
29+
"localhost",
30+
];
31+
2232
interface Env {
2333
PROXY_CONTROLLER: Fetcher;
2434
PROXY_CONTROLLER_AUTH_SECRET: string;
@@ -451,6 +461,40 @@ export class InspectorProxyWorker implements DurableObject {
451461
}
452462

453463
async handleDevToolsWebSocketUpgradeRequest(req: Request) {
464+
// Validate `Host` header
465+
let hostHeader = req.headers.get("Host");
466+
if (hostHeader == null) return new Response(null, { status: 400 });
467+
try {
468+
const host = new URL(`http://${hostHeader}`);
469+
if (!ALLOWED_HOST_HOSTNAMES.includes(host.hostname)) {
470+
return new Response("Disallowed `Host` header", { status: 401 });
471+
}
472+
} catch {
473+
return new Response("Expected `Host` header", { status: 400 });
474+
}
475+
// Validate `Origin` header
476+
let originHeader = req.headers.get("Origin");
477+
if (originHeader === null && !req.headers.has("User-Agent")) {
478+
// VSCode doesn't send an `Origin` header, but also doesn't send a
479+
// `User-Agent` header, so allow an empty origin in this case.
480+
originHeader = "http://localhost";
481+
}
482+
if (originHeader === null) {
483+
return new Response("Expected `Origin` header", { status: 400 });
484+
}
485+
try {
486+
const origin = new URL(originHeader);
487+
const allowed = ALLOWED_ORIGIN_HOSTNAMES.some((rule) => {
488+
if (typeof rule === "string") return origin.hostname === rule;
489+
else return rule.test(origin.hostname);
490+
});
491+
if (!allowed) {
492+
return new Response("Disallowed `Origin` header", { status: 401 });
493+
}
494+
} catch {
495+
return new Response("Expected `Origin` header", { status: 400 });
496+
}
497+
454498
// DevTools attempting to connect
455499
this.sendDebugLog("DEVTOOLS WEBSOCKET TRYING TO CONNECT");
456500

0 commit comments

Comments
 (0)