Skip to content

Commit

Permalink
Inject CF-Connecting-IP from workerd clientIp (#7702)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
penalosa authored Jan 9, 2025
1 parent 65a3e35 commit 78bdec5
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 22 deletions.
5 changes: 5 additions & 0 deletions .changeset/red-lamps-obey.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 2 additions & 9 deletions packages/miniflare/src/http/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CORE_PLUGIN.sharedOptions>
): Promise<{ http: HttpOptions } | { https: Socket_Https }> {
Expand All @@ -34,7 +28,6 @@ export async function getEntrySocketHttpOptions(
if (privateKey && certificateChain) {
return {
https: {
options: ENTRY_SOCKET_HTTP_OPTIONS,
tlsOptions: {
keypair: {
privateKey: privateKey,
Expand All @@ -44,7 +37,7 @@ export async function getEntrySocketHttpOptions(
},
};
} else {
return { http: ENTRY_SOCKET_HTTP_OPTIONS };
return { http: {} };
}
}

Expand Down
3 changes: 1 addition & 2 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
coupleWebSocket,
DispatchFetch,
DispatchFetchDispatcher,
ENTRY_SOCKET_HTTP_OPTIONS,
fetch,
getAccessibleHosts,
getEntrySocketHttpOptions,
Expand Down Expand Up @@ -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",
});
}
Expand Down
42 changes: 31 additions & 11 deletions packages/miniflare/src/workers/core/entry.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ const encoder = new TextEncoder();

function getUserRequest(
request: Request<unknown, IncomingRequestCfProperties>,
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),
Expand Down Expand Up @@ -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
Expand All @@ -107,6 +99,18 @@ function getUserRequest(
request.headers.set("Host", url.host);
}

if (clientIp && !request.headers.get("CF-Connecting-IP")) {
const ipv4Regex = /(?<ip>.*?):\d+/;
const ipv6Regex = /\[(?<ip>.*?)\]:\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);
Expand Down Expand Up @@ -343,6 +347,22 @@ export default <ExportedHandler<Env>>{
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);
Expand All @@ -356,7 +376,7 @@ export default <ExportedHandler<Env>>{
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();
Expand Down
79 changes: 79 additions & 0 deletions packages/miniflare/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Omit<Worker_Module, "name">> = {
Expand Down

0 comments on commit 78bdec5

Please sign in to comment.