diff --git a/.changeset/brave-planes-dance.md b/.changeset/brave-planes-dance.md new file mode 100644 index 0000000000..717d5a6434 --- /dev/null +++ b/.changeset/brave-planes-dance.md @@ -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. diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index bf5817ca3f..b212c38dc9 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -133,6 +133,7 @@ import { CacheHeaders, CoreBindings, CoreHeaders, + CorePaths, LogLevel, Mutex, SharedHeaders, @@ -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); }); diff --git a/packages/miniflare/src/plugins/core/constants.ts b/packages/miniflare/src/plugins/core/constants.ts index 90f2df5139..3ff40a274f 100644 --- a/packages/miniflare/src/plugins/core/constants.ts +++ b/packages/miniflare/src/plugins/core/constants.ts @@ -1,3 +1,4 @@ +import { CorePaths } from "../../workers"; import type { DoRawQueryResult, DoSqlWithParams, @@ -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) diff --git a/packages/miniflare/src/plugins/core/proxy/client.ts b/packages/miniflare/src/plugins/core/proxy/client.ts index 9ed4882907..9ae1a17e93 100644 --- a/packages/miniflare/src/plugins/core/proxy/client.ts +++ b/packages/miniflare/src/plugins/core/proxy/client.ts @@ -10,6 +10,7 @@ import { prefixStream, readPrefix } from "../../../shared"; import { Awaitable, CoreHeaders, + CorePaths, createHTTPReducers, createHTTPRevivers, isDurableObjectStub, @@ -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 @@ -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 { @@ -720,9 +724,12 @@ class ProxyStubHandler #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); diff --git a/packages/miniflare/src/plugins/core/proxy/fetch-sync.ts b/packages/miniflare/src/plugins/core/proxy/fetch-sync.ts index fc9c7122df..bd3443a675 100644 --- a/packages/miniflare/src/plugins/core/proxy/fetch-sync.ts +++ b/packages/miniflare/src/plugins/core/proxy/fetch-sync.ts @@ -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" @@ -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(); diff --git a/packages/miniflare/src/workers/core/constants.ts b/packages/miniflare/src/workers/core/constants.ts index 865a45cfb3..21b23e380e 100644 --- a/packages/miniflare/src/workers/core/constants.ts +++ b/packages/miniflare/src/workers/core/constants.ts @@ -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", @@ -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 = { diff --git a/packages/miniflare/src/workers/core/entry.worker.ts b/packages/miniflare/src/workers/core/entry.worker.ts index 6b71bbf1b6..b9f6723e57 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -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"; @@ -469,9 +468,14 @@ export default >{ }; 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. @@ -487,8 +491,8 @@ export default >{ 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, @@ -514,18 +518,18 @@ export default >{ 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", @@ -534,7 +538,7 @@ export default >{ 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}\``, } ) ); @@ -542,7 +546,7 @@ export default >{ return await handleScheduled(url.searchParams, service); } - if (url.pathname === "/cdn-cgi/handler/email") { + if (url.pathname === CorePaths.EMAIL) { return await handleEmail( url.searchParams, request, @@ -552,9 +556,9 @@ export default >{ ); } - 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 } ); } diff --git a/packages/miniflare/src/workers/core/proxy.worker.ts b/packages/miniflare/src/workers/core/proxy.worker.ts index d2e386fbc7..6a6283f609 100644 --- a/packages/miniflare/src/workers/core/proxy.worker.ts +++ b/packages/miniflare/src/workers/core/proxy.worker.ts @@ -270,7 +270,9 @@ 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); @@ -278,6 +280,7 @@ export class ProxyServer implements DurableObject { 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); diff --git a/packages/miniflare/test/plugins/core/proxy/client.spec.ts b/packages/miniflare/test/plugins/core/proxy/client.spec.ts index 1112cf7f74..d30d29d827 100644 --- a/packages/miniflare/test/plugins/core/proxy/client.spec.ts +++ b/packages/miniflare/test/plugins/core/proxy/client.spec.ts @@ -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, @@ -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; + }>(); + + 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, }) => { @@ -329,11 +352,12 @@ 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(); const req = http.get( - url, + proxyUrl, { setHost: false, headers: { "MF-Op": "GET", Host: "localhost" } }, (res) => statusPromise.resolve(res.statusCode ?? 0) ); @@ -341,20 +365,25 @@ describe("ProxyClient", () => { 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) }); });