Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/brave-planes-dance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

Move internal proxy endpoint to reserved `/cdn-cgi/` path

The internal HTTP endpoint used by `getPlatformProxy` has been moved to a reserved path. This is an internal change with no impact on the `getPlatformProxy` API.
3 changes: 2 additions & 1 deletion packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ import {
CacheHeaders,
CoreBindings,
CoreHeaders,
CorePaths,
LogLevel,
Mutex,
SharedHeaders,
Expand Down Expand Up @@ -1384,7 +1385,7 @@ export class Miniflare {
const { pathname } = new URL(req.url ?? "", "http://localhost");

// If this is the path for live-reload, handle the request
if (pathname === "/cdn-cgi/mf/reload") {
if (pathname === CorePaths.LIVE_RELOAD) {
this.#liveReloadServer.handleUpgrade(req, socket, head, (ws) => {
this.#liveReloadServer.emit("connection", ws, req);
});
Expand Down
5 changes: 3 additions & 2 deletions packages/miniflare/src/plugins/core/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CorePaths } from "../../workers";
import type {
DoRawQueryResult,
DoSqlWithParams,
Expand All @@ -12,9 +13,9 @@ export const SERVICE_LOCAL_EXPLORER = `${CORE_PLUGIN_NAME}:local-explorer`;
// Disk service for local explorer UI assets
export const LOCAL_EXPLORER_DISK = `${CORE_PLUGIN_NAME}:local-explorer-disk`;
// URL path prefix where the local explorer UI is served
export const LOCAL_EXPLORER_BASE_PATH = "/cdn-cgi/explorer";
export const LOCAL_EXPLORER_BASE_PATH = CorePaths.EXPLORER;
// URL path prefix for the local explorer API endpoints
export const LOCAL_EXPLORER_API_PATH = `${LOCAL_EXPLORER_BASE_PATH}/api`;
export const LOCAL_EXPLORER_API_PATH = `${CorePaths.EXPLORER}/api`;
// Service prefix for all regular user workers
const SERVICE_USER_PREFIX = `${CORE_PLUGIN_NAME}:user`;
// Service prefix for `workerd`'s builtin services (network, external, disk)
Expand Down
13 changes: 10 additions & 3 deletions packages/miniflare/src/plugins/core/proxy/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { prefixStream, readPrefix } from "../../../shared";
import {
Awaitable,
CoreHeaders,
CorePaths,
createHTTPReducers,
createHTTPRevivers,
isDurableObjectStub,
Expand Down Expand Up @@ -97,7 +98,10 @@ export class ProxyClient {
#bridge: ProxyClientBridge;

constructor(runtimeEntryURL: URL, dispatchFetch: DispatchFetch) {
this.#bridge = new ProxyClientBridge(runtimeEntryURL, dispatchFetch);
this.#bridge = new ProxyClientBridge(
new URL(CorePaths.PLATFORM_PROXY, runtimeEntryURL),
dispatchFetch
);
}

// Lazily initialise proxies as required
Expand All @@ -120,7 +124,7 @@ export class ProxyClient {
setRuntimeEntryURL(runtimeEntryURL: URL) {
// This function will be called whenever the runtime restarts. The URL may
// be different if the port has changed.
this.#bridge.url = runtimeEntryURL;
this.#bridge.url = new URL(CorePaths.PLATFORM_PROXY, runtimeEntryURL);
}

dispose(): Promise<void> {
Expand Down Expand Up @@ -720,9 +724,12 @@ class ProxyStubHandler<T extends object>
#fetcherFetchCall(args: unknown[]) {
// @ts-expect-error `...args` isn't type-safe here, but `undici` should
// validate types at runtime, and throw appropriate errors
const request = new Request(...args);
const userRequest = new Request(...args);
// Create a new request with the proxy URL, preserving the original request
const request = new Request(this.bridge.url, userRequest);
// If adding new headers here, remember to `delete()` them in `ProxyServer`
// before calling `fetch()`.
request.headers.set(CoreHeaders.OP_ORIGINAL_URL, userRequest.url);
request.headers.set(CoreHeaders.OP_SECRET, PROXY_SECRET_HEX);
request.headers.set(CoreHeaders.OP, ProxyOps.CALL);
request.headers.set(CoreHeaders.OP_TARGET, this.#stringifiedTarget);
Expand Down
23 changes: 12 additions & 11 deletions packages/miniflare/src/plugins/core/proxy/fetch-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,18 +50,18 @@ let dispatcher;

port.addEventListener("message", async (event) => {
const { id, method, url, headers, body } = event.data;
if (dispatcherUrl !== url) {
dispatcherUrl = url;
dispatcher = new Pool(url, {
connect: { rejectUnauthorized: false },
// Disable timeouts for local dev — long-running responses (streaming,
try {
if (dispatcherUrl !== url) {
dispatcherUrl = url;
dispatcher = new Pool(new URL(url).origin, {
connect: { rejectUnauthorized: false },
// Disable timeouts for local dev — long-running responses (streaming,
// slow uploads, long-polling) should not be killed by undici defaults.
headersTimeout: 0,
bodyTimeout: 0,
});
}
headers["${CoreHeaders.OP_SYNC}"] = "true";
try {
});
}
headers["${CoreHeaders.OP_SYNC}"] = "true";
// body cannot be a ReadableStream, so no need to specify duplex
const response = await fetch(url, { method, headers, body, dispatcher });
const responseBody = response.headers.get("${CoreHeaders.OP_RESULT_TYPE}") === "ReadableStream"
Expand All @@ -86,9 +86,10 @@ port.addEventListener("message", async (event) => {
// If error failed to serialise, post simplified version
port.postMessage({ id, error: new Error(String(error)) });
}
} finally {
Atomics.store(notifyHandle, /* index */ 0, /* value */ 1);
Atomics.notify(notifyHandle, /* index */ 0);
}
Atomics.store(notifyHandle, /* index */ 0, /* value */ 1);
Atomics.notify(notifyHandle, /* index */ 0);
});

port.start();
Expand Down
22 changes: 22 additions & 0 deletions packages/miniflare/src/workers/core/constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
/**
* Reserved `/cdn-cgi/` paths for internal Miniflare endpoints.
* These paths are reserved by Cloudflare's network and won't conflict with user routes.
*/
export const CorePaths = {
/** Magic proxy used by getPlatformProxy */
PLATFORM_PROXY: "/cdn-cgi/platform-proxy",
/** Trigger scheduled event handlers */
SCHEDULED: "/cdn-cgi/handler/scheduled",
/** Trigger email event handlers */
EMAIL: "/cdn-cgi/handler/email",
/** Handler path prefix for validation */
HANDLER_PREFIX: "/cdn-cgi/handler/",
/** Live reload WebSocket endpoint */
LIVE_RELOAD: "/cdn-cgi/mf/reload",
/** Local explorer UI and API */
EXPLORER: "/cdn-cgi/explorer",
/** Legacy way to trigger scheduled event handlers */
LEGACY_SCHEDULED: "/cdn-cgi/mf/scheduled",
} as const;

export const CoreHeaders = {
CUSTOM_FETCH_SERVICE: "MF-Custom-Fetch-Service",
CUSTOM_NODE_SERVICE: "MF-Custom-Node-Service",
Expand Down Expand Up @@ -25,6 +46,7 @@ export const CoreHeaders = {
OP_SYNC: "MF-Op-Sync",
OP_STRINGIFIED_SIZE: "MF-Op-Stringified-Size",
OP_RESULT_TYPE: "MF-Op-Result-Type",
OP_ORIGINAL_URL: "MF-Op-Original-URL",
} as const;

export const CoreBindings = {
Expand Down
36 changes: 20 additions & 16 deletions packages/miniflare/src/workers/core/entry.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ import {
yellow,
} from "kleur/colors";
import { HttpError, LogLevel, SharedHeaders } from "miniflare:shared";
import { LOCAL_EXPLORER_BASE_PATH } from "../../plugins/core/constants";
import { isCompressedByCloudflareFL } from "../../shared/mime-types";
import { CoreBindings, CoreHeaders } from "./constants";
import { CoreBindings, CoreHeaders, CorePaths } from "./constants";
import { handleEmail } from "./email";
import { STATUS_CODES } from "./http";
import { matchRoutes, WorkerRoute } from "./routing";
Expand Down Expand Up @@ -469,9 +468,14 @@ export default <ExportedHandler<Env>>{
};
request = new Request(request, { cf });

// The magic proxy client (used by getPlatformProxy) will always specify an operation
const isProxy = request.headers.get(CoreHeaders.OP) !== null;
if (isProxy) return handleProxy(request, env);
// The magic proxy client (used by getPlatformProxy)
if (new URL(request.url).pathname === CorePaths.PLATFORM_PROXY) {
if (request.headers.get(CoreHeaders.OP) !== null) {
return handleProxy(request, env);
}

return new Response("Invalid proxy request", { status: 400 });
}

// `dispatchFetch()` will always inject this header. When
// calling this function, we never want to display the pretty-error page.
Expand All @@ -487,8 +491,8 @@ export default <ExportedHandler<Env>>{
if (env[CoreBindings.SERVICE_LOCAL_EXPLORER]) {
const preRewriteUrl = new URL(request.url);
if (
preRewriteUrl.pathname === LOCAL_EXPLORER_BASE_PATH ||
preRewriteUrl.pathname.startsWith(`${LOCAL_EXPLORER_BASE_PATH}/`)
preRewriteUrl.pathname === CorePaths.EXPLORER ||
preRewriteUrl.pathname.startsWith(`${CorePaths.EXPLORER}/`)
) {
validateLocalExplorerRequest(
request,
Expand All @@ -514,18 +518,18 @@ export default <ExportedHandler<Env>>{
try {
if (env[CoreBindings.SERVICE_LOCAL_EXPLORER]) {
if (
url.pathname === LOCAL_EXPLORER_BASE_PATH ||
url.pathname.startsWith(`${LOCAL_EXPLORER_BASE_PATH}/`)
url.pathname === CorePaths.EXPLORER ||
url.pathname.startsWith(`${CorePaths.EXPLORER}/`)
) {
return await env[CoreBindings.SERVICE_LOCAL_EXPLORER].fetch(request);
}
}
if (env[CoreBindings.TRIGGER_HANDLERS]) {
if (
url.pathname === "/cdn-cgi/handler/scheduled" ||
/* legacy URL path */ url.pathname === "/cdn-cgi/mf/scheduled"
url.pathname === CorePaths.SCHEDULED ||
/* legacy URL path */ url.pathname === CorePaths.LEGACY_SCHEDULED
) {
if (url.pathname === "/cdn-cgi/mf/scheduled") {
if (url.pathname === CorePaths.LEGACY_SCHEDULED) {
ctx.waitUntil(
env[CoreBindings.SERVICE_LOOPBACK].fetch(
"http://localhost/core/log",
Expand All @@ -534,15 +538,15 @@ export default <ExportedHandler<Env>>{
headers: {
[SharedHeaders.LOG_LEVEL]: LogLevel.WARN.toString(),
},
body: `Triggering scheduled handlers via a request to \`/cdn-cgi/mf/scheduled\` is deprecated, and will be removed in a future version of Miniflare. Instead, send a request to \`/cdn-cgi/handler/scheduled\``,
body: `Triggering scheduled handlers via a request to \`${CorePaths.LEGACY_SCHEDULED}\` is deprecated, and will be removed in a future version of Miniflare. Instead, send a request to \`${CorePaths.SCHEDULED}\``,
}
)
);
}
return await handleScheduled(url.searchParams, service);
}

if (url.pathname === "/cdn-cgi/handler/email") {
if (url.pathname === CorePaths.EMAIL) {
return await handleEmail(
url.searchParams,
request,
Expand All @@ -552,9 +556,9 @@ export default <ExportedHandler<Env>>{
);
}

if (url.pathname.startsWith("/cdn-cgi/handler/")) {
if (url.pathname.startsWith(CorePaths.HANDLER_PREFIX)) {
return new Response(
`"${url.pathname}" is not a valid handler. Did you mean to use "/cdn-cgi/handler/scheduled" or "/cdn-cgi/handler/email"?`,
`"${url.pathname}" is not a valid handler. Did you mean to use "${CorePaths.SCHEDULED}" or "${CorePaths.EMAIL}"?`,
{ status: 404 }
);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/miniflare/src/workers/core/proxy.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,14 +270,17 @@ export class ProxyServer implements DurableObject {

// See `isFetcherFetch()` comment for why this special
if (isFetcherFetch(targetName, keyHeader)) {
const originalUrl = request.headers.get(CoreHeaders.ORIGINAL_URL);
const originalUrl =
request.headers.get(CoreHeaders.OP_ORIGINAL_URL) ??
request.headers.get(CoreHeaders.ORIGINAL_URL);
const url = new URL(originalUrl ?? request.url);
// Create a new request to allow header mutation and use original URL
request = new Request(url, request);
request.headers.delete(CoreHeaders.OP_SECRET);
request.headers.delete(CoreHeaders.OP);
request.headers.delete(CoreHeaders.OP_TARGET);
request.headers.delete(CoreHeaders.OP_KEY);
request.headers.delete(CoreHeaders.OP_ORIGINAL_URL);
request.headers.delete(CoreHeaders.ORIGINAL_URL);
request.headers.delete(CoreHeaders.DISABLE_PRETTY_ERROR);
return func.call(target, request);
Expand Down
37 changes: 33 additions & 4 deletions packages/miniflare/test/plugins/core/proxy/client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { text } from "node:stream/consumers";
import { ReadableStream, WritableStream } from "node:stream/web";
import util from "node:util";
import {
CorePaths,
DeferredPromise,
fetch,
MessageEvent,
Expand Down Expand Up @@ -56,6 +57,28 @@ describe("ProxyClient", () => {
expect(event.data).toBe("echo:hello");
});

test("preserves original URL for service binding fetch", async ({
expect,
}) => {
const mf = new Miniflare({
script: nullScript,
serviceBindings: {
CUSTOM(request: Request) {
return new Response(request.url);
},
},
});
useDispose(mf);

const { CUSTOM } = await mf.getBindings<{
CUSTOM: ReplaceWorkersTypes<Fetcher>;
}>();

const url = "https://placeholder/path?query=value";
const res = await CUSTOM.fetch(url);
expect(await res.text()).toBe(url);
});

test("supports serialising multiple ReadableStreams, Blobs and Files", async ({
expect,
}) => {
Expand Down Expand Up @@ -329,32 +352,38 @@ describe("ProxyClient", () => {
const mf = new Miniflare({ script: nullScript });
useDispose(mf);
const url = await mf.ready;
const proxyUrl = new URL(CorePaths.PLATFORM_PROXY, url);

// Check validates `Host` header
const statusPromise = new DeferredPromise<number>();
const req = http.get(
url,
proxyUrl,
{ setHost: false, headers: { "MF-Op": "GET", Host: "localhost" } },
(res) => statusPromise.resolve(res.statusCode ?? 0)
);
req.on("error", (error) => statusPromise.reject(error));
expect(await statusPromise).toBe(401);

// Check validates `MF-Op-Secret` header
let res = await fetch(url, {
let res = await fetch(proxyUrl, {
headers: { "MF-Op": "GET" }, // (missing)
});
expect(res.status).toBe(401);
await res.arrayBuffer(); // (drain)
res = await fetch(url, {
res = await fetch(proxyUrl, {
headers: { "MF-Op": "GET", "MF-Op-Secret": "aaaa" }, // (too short)
});
expect(res.status).toBe(401);
await res.arrayBuffer(); // (drain)
res = await fetch(url, {
res = await fetch(proxyUrl, {
headers: { "MF-Op": "GET", "MF-Op-Secret": "a".repeat(32) }, // (wrong)
});
expect(res.status).toBe(401);
await res.arrayBuffer(); // (drain)

// Check requests to proxy path without MF-Op header return 400
res = await fetch(proxyUrl);
expect(res.status).toBe(400);
await res.arrayBuffer(); // (drain)
});
});
Loading