From 78bdec59ce880365b0318eb94d4176b53e950f66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Thu, 9 Jan 2025 14:34:13 +0000 Subject: [PATCH] Inject `CF-Connecting-IP` from workerd `clientIp` (#7702) * Inject CF-Connecting-IP from workerd clientIp * Create red-lamps-obey.md * Handle ipv6 * Skip tests on windows * More tests for windows * Add more comments * skip on windows --- .changeset/red-lamps-obey.md | 5 ++ packages/miniflare/src/http/server.ts | 11 +-- packages/miniflare/src/index.ts | 3 +- .../src/workers/core/entry.worker.ts | 42 +++++++--- packages/miniflare/test/index.spec.ts | 79 +++++++++++++++++++ 5 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 .changeset/red-lamps-obey.md diff --git a/.changeset/red-lamps-obey.md b/.changeset/red-lamps-obey.md new file mode 100644 index 000000000000..93c477bcab5c --- /dev/null +++ b/.changeset/red-lamps-obey.md @@ -0,0 +1,5 @@ +--- +"miniflare": minor +--- + +Support the `CF-Connecting-IP` header, which will be available in your Worker to determine the IP address of the client that initiated a request. diff --git a/packages/miniflare/src/http/server.ts b/packages/miniflare/src/http/server.ts index 1792a621d10f..76f617255f3d 100644 --- a/packages/miniflare/src/http/server.ts +++ b/packages/miniflare/src/http/server.ts @@ -2,15 +2,9 @@ import fs from "fs/promises"; import { z } from "zod"; import { CORE_PLUGIN } from "../plugins"; import { HttpOptions, Socket_Https } from "../runtime"; -import { Awaitable, CoreHeaders } from "../workers"; +import { Awaitable } from "../workers"; import { CERT, KEY } from "./cert"; -export const ENTRY_SOCKET_HTTP_OPTIONS: HttpOptions = { - // Even though we inject a `cf` object in the entry worker, allow it to - // be customised via `dispatchFetch` - cfBlobHeader: CoreHeaders.CF_BLOB, -}; - export async function getEntrySocketHttpOptions( coreOpts: z.infer ): Promise<{ http: HttpOptions } | { https: Socket_Https }> { @@ -34,7 +28,6 @@ export async function getEntrySocketHttpOptions( if (privateKey && certificateChain) { return { https: { - options: ENTRY_SOCKET_HTTP_OPTIONS, tlsOptions: { keypair: { privateKey: privateKey, @@ -44,7 +37,7 @@ export async function getEntrySocketHttpOptions( }, }; } else { - return { http: ENTRY_SOCKET_HTTP_OPTIONS }; + return { http: {} }; } } diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 2c5987a4c216..398456c1055c 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -28,7 +28,6 @@ import { coupleWebSocket, DispatchFetch, DispatchFetchDispatcher, - ENTRY_SOCKET_HTTP_OPTIONS, fetch, getAccessibleHosts, getEntrySocketHttpOptions, @@ -1117,7 +1116,7 @@ export class Miniflare { sockets.push({ name: SOCKET_ENTRY_LOCAL, service: { name: SERVICE_ENTRY }, - http: ENTRY_SOCKET_HTTP_OPTIONS, + http: {}, address: "127.0.0.1:0", }); } diff --git a/packages/miniflare/src/workers/core/entry.worker.ts b/packages/miniflare/src/workers/core/entry.worker.ts index 884c818adc7c..402b71e75e5e 100644 --- a/packages/miniflare/src/workers/core/entry.worker.ts +++ b/packages/miniflare/src/workers/core/entry.worker.ts @@ -35,7 +35,8 @@ const encoder = new TextEncoder(); function getUserRequest( request: Request, - env: Env + env: Env, + clientIp: string | undefined ) { // The ORIGINAL_URL header is added to outbound requests from Miniflare, // triggered either by calling Miniflare.#dispatchFetch(request), @@ -89,15 +90,6 @@ function getUserRequest( // special handling to allow this if a `Request` instance is passed. // See https://github.com/cloudflare/workerd/issues/1122 for more details. request = new Request(url, request); - if (request.cf === undefined) { - const cf: IncomingRequestCfProperties = { - ...env[CoreBindings.JSON_CF_BLOB], - // Defaulting to empty string to preserve undefined `Accept-Encoding` - // through Wrangler's proxy worker. - clientAcceptEncoding: request.headers.get("Accept-Encoding") ?? "", - }; - request = new Request(request, { cf }); - } // `Accept-Encoding` is always set to "br, gzip" in Workers: // https://developers.cloudflare.com/fundamentals/reference/http-request-headers/#accept-encoding @@ -107,6 +99,18 @@ function getUserRequest( request.headers.set("Host", url.host); } + if (clientIp && !request.headers.get("CF-Connecting-IP")) { + const ipv4Regex = /(?.*?):\d+/; + const ipv6Regex = /\[(?.*?)\]:\d+/; + const ip = + clientIp.match(ipv6Regex)?.groups?.ip ?? + clientIp.match(ipv4Regex)?.groups?.ip; + + if (ip) { + request.headers.set("CF-Connecting-IP", ip); + } + } + request.headers.delete(CoreHeaders.PROXY_SHARED_SECRET); request.headers.delete(CoreHeaders.ORIGINAL_URL); request.headers.delete(CoreHeaders.DISABLE_PRETTY_ERROR); @@ -343,6 +347,22 @@ export default >{ async fetch(request, env, ctx) { const startTime = Date.now(); + const clientIp = request.cf?.clientIp as string; + + // Parse this manually (rather than using the `cfBlobHeader` config property in workerd to parse it into request.cf) + // This is because we want to have access to the clientIp, which workerd puts in request.cf if no cfBlobHeader is provided + const clientCfBlobHeader = request.headers.get(CoreHeaders.CF_BLOB); + + const cf: IncomingRequestCfProperties = clientCfBlobHeader + ? JSON.parse(clientCfBlobHeader) + : { + ...env[CoreBindings.JSON_CF_BLOB], + // Defaulting to empty string to preserve undefined `Accept-Encoding` + // through Wrangler's proxy worker. + clientAcceptEncoding: request.headers.get("Accept-Encoding") ?? "", + }; + request = new Request(request, { cf }); + // The proxy client will always specify an operation const isProxy = request.headers.get(CoreHeaders.OP) !== null; if (isProxy) return handleProxy(request, env); @@ -356,7 +376,7 @@ export default >{ const clientAcceptEncoding = request.headers.get("Accept-Encoding"); try { - request = getUserRequest(request, env); + request = getUserRequest(request, env, clientIp); } catch (e) { if (e instanceof HttpError) { return e.toResponse(); diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index ab3a872c05c8..f19177b2f06c 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -2610,6 +2610,85 @@ test("Miniflare: getCf() returns a user provided cf object", async (t) => { t.deepEqual(cf, { myFakeField: "test" }); }); +test("Miniflare: dispatchFetch() can override cf", async (t) => { + const mf = new Miniflare({ + script: + "export default { fetch(request) { return Response.json(request.cf) } }", + modules: true, + cf: { + myFakeField: "test", + }, + }); + t.teardown(() => mf.dispose()); + + const cf = await mf.dispatchFetch("http://example.com/", { + cf: { myFakeField: "test2" }, + }); + const cfJson = (await cf.json()) as { myFakeField: string }; + t.deepEqual(cfJson.myFakeField, "test2"); +}); + +test("Miniflare: CF-Connecting-IP is injected", async (t) => { + const mf = new Miniflare({ + script: + "export default { fetch(request) { return new Response(request.headers.get('CF-Connecting-IP')) } }", + modules: true, + cf: { + myFakeField: "test", + }, + }); + t.teardown(() => mf.dispose()); + + const ip = await mf.dispatchFetch("http://example.com/"); + // Tracked in https://github.com/cloudflare/workerd/issues/3310 + if (!isWindows) { + t.deepEqual(await ip.text(), "127.0.0.1"); + } else { + t.deepEqual(await ip.text(), ""); + } +}); + +test("Miniflare: CF-Connecting-IP is injected (ipv6)", async (t) => { + const mf = new Miniflare({ + script: + "export default { fetch(request) { return new Response(request.headers.get('CF-Connecting-IP')) } }", + modules: true, + cf: { + myFakeField: "test", + }, + host: "::1", + }); + t.teardown(() => mf.dispose()); + + const ip = await mf.dispatchFetch("http://example.com/"); + + // Tracked in https://github.com/cloudflare/workerd/issues/3310 + if (!isWindows) { + t.deepEqual(await ip.text(), "::1"); + } else { + t.deepEqual(await ip.text(), ""); + } +}); + +test("Miniflare: CF-Connecting-IP is preserved when present", async (t) => { + const mf = new Miniflare({ + script: + "export default { fetch(request) { return new Response(request.headers.get('CF-Connecting-IP')) } }", + modules: true, + cf: { + myFakeField: "test", + }, + }); + t.teardown(() => mf.dispose()); + + const ip = await mf.dispatchFetch("http://example.com/", { + headers: { + "CF-Connecting-IP": "128.0.0.1", + }, + }); + t.deepEqual(await ip.text(), "128.0.0.1"); +}); + test("Miniflare: can use module fallback service", async (t) => { const modulesRoot = "/"; const modules: Record> = {