Skip to content
8 changes: 4 additions & 4 deletions src/selfhost/cf-workers-shim.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Minimal stand-in for the `cloudflare:workers` module on the Node self-host runtime. The only import of it
// in the codebase is `DurableObject` (auth/rate-limit.ts → the RateLimiter DO). That DO is NEVER instantiated
// on self-host — env.RATE_LIMITER is undefined, so enforceRateLimit returns null before any DO is touched —
// so this base class only needs to make the import + `extends DurableObject` resolve. The self-host esbuild
// build aliases `cloudflare:workers` to this file (see the Docker build / build:selfhost script).
// in the codebase is `DurableObject` (auth/rate-limit.ts → the RateLimiter DO). Self-host binds a Redis
// RATE_LIMITER (server.ts) that speaks the same DO fetch surface — this base class only needs to make the
// import + `extends DurableObject` resolve. The self-host esbuild build aliases `cloudflare:workers` to this
// file (see the Docker build / build:selfhost script).
export class DurableObject<E = unknown> {
constructor(
protected ctx?: unknown,
Expand Down
109 changes: 109 additions & 0 deletions src/selfhost/trusted-client-ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Resolve the real client IP for self-host rate limiting (#critical: cf-connecting-ip spoof).
//
// On Cloudflare Workers, `cf-connecting-ip` is edge-set and safe for `clientIp()` in auth/rate-limit.ts.
// On Node self-host the same header is just another request header — fully attacker-controlled — while
// Redis RATE_LIMITER IS bound (server.ts). Caddy (caddy/Caddyfile) injects X-Real-IP / X-Forwarded-For
// from `{remote_host}` but does not set or strip `cf-connecting-ip`.
//
// This module overwrites `cf-connecting-ip` at the Node edge before the Worker fetch runs:
// • Always delete any client-supplied `cf-connecting-ip` (never trust it on Node).
// • If the TCP peer is a private/link-local/loopback hop (compose Caddy → app), prefer Caddy's
// X-Real-IP, then the leftmost X-Forwarded-For hop.
// • Otherwise use the TCP peer (direct :8787 expose) and ignore proxy headers (client-spoofable).
// Workers remain unchanged: they never call this helper.
export function resolveTrustedClientIp(
peerAddress: string | undefined,
headers: Headers,
): string {
const peer = normalizeIpAddress(stripIpv4MappedPrefix(peerAddress));
const xReal = normalizeIpAddress(headers.get("x-real-ip") ?? undefined);
const xff = normalizeIpAddress(headers.get("x-forwarded-for")?.split(",")[0]?.trim());

if (peer && isPrivateOrLinkLocal(peer)) {
return xReal ?? xff ?? peer;
}
return peer ?? "unknown-ip";
}

/** Read the TCP peer from @hono/node-server's documented fetch second argument (`HttpBindings` /
* `Http2Bindings`: `incoming.socket.remoteAddress`). Exported so server.ts wiring is unit-testable
* without booting serve(). Accepts `unknown` so HttpBindings | Http2Bindings both type-check. */
export function peerRemoteAddress(nodeEnv: unknown): string | undefined {
if (!nodeEnv || typeof nodeEnv !== "object") return undefined;
const incoming = (nodeEnv as { incoming?: unknown }).incoming;
if (!incoming || typeof incoming !== "object") return undefined;
const socket = (incoming as { socket?: unknown }).socket;
if (!socket || typeof socket !== "object") return undefined;
const remote = (socket as { remoteAddress?: unknown }).remoteAddress;
return typeof remote === "string" ? remote : undefined;
}

/** Return a Request whose `cf-connecting-ip` is the Node-edge-trusted client IP (see resolveTrustedClientIp). */
export function withTrustedClientIp(request: Request, peerAddress: string | undefined): Request {
const headers = new Headers(request.headers);
headers.delete("cf-connecting-ip");
const clientIp = resolveTrustedClientIp(peerAddress, headers);
if (clientIp !== "unknown-ip") headers.set("cf-connecting-ip", clientIp);
return new Request(request, { headers });
}

function stripIpv4MappedPrefix(value: string | undefined): string | undefined {
if (!value) return undefined;
return value.startsWith("::ffff:") ? value.slice("::ffff:".length) : value;
}

function normalizeIpAddress(value: string | undefined): string | undefined {
const trimmed = value?.trim();
if (!trimmed || !isValidIpAddress(trimmed)) return undefined;
if (trimmed.startsWith("[") && trimmed.endsWith("]")) return trimmed.slice(1, -1);
return trimmed;
}

function isValidIpAddress(value: string): boolean {
return isValidIpv4(value) || isValidIpv6(value);
}

function isValidIpv4(value: string): boolean {
const parts = value.split(".");
if (parts.length !== 4) return false;
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return false;
const octet = Number(part);
if (octet < 0 || octet > 255) return false;
}
return true;
}

function isValidIpv6(value: string): boolean {
let candidate = value;
if (candidate.startsWith("[") && candidate.endsWith("]")) candidate = candidate.slice(1, -1);
if (!candidate.includes(":") || !/^[0-9a-fA-F:.]+$/.test(candidate)) return false;
if (candidate.split("::").length > 2) return false;
const segments = candidate.split(":");
if (segments.length > 8) return false;
let hasHexSegment = false;
for (const segment of segments) {
if (segment === "") continue;
if (!/^[0-9a-fA-F]{1,4}$/.test(segment)) return false;
hasHexSegment = true;
}
return hasHexSegment;
}

/** RFC1918 / link-local / loopback — the hop we see when Caddy (or another compose proxy) fronts the app. */
export function isPrivateOrLinkLocal(ip: string): boolean {
if (ip === "::1" || ip === "0:0:0:0:0:0:0:1") return true;
if (ip.startsWith("fe80:") || ip.startsWith("FE80:")) return true;
// Unique-local IPv6 (fc00::/7)
if (/^[fF][cCdD]/.test(ip)) return true;

const parts = ip.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n))) return false;
const [a, b] = parts as [number, number, number, number];
if (a === 10) return true;
if (a === 127) return true;
if (a === 192 && b === 168) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 169 && b === 254) return true;
return false;
}
11 changes: 10 additions & 1 deletion src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { delimiter, join } from "node:path";
import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
import packageJson from "../package.json";
import worker from "./index";
import { githubRestRateLimitRemainingSamples } from "./github/client";
Expand Down Expand Up @@ -44,6 +45,7 @@ import { createOrbRelayRegistrationState, isOrbBrokerMode, registerOrbRelayTarge
import { exportOrbBatch } from "./selfhost/orb-collector";
import { createD1Adapter, nodeSqliteDriver } from "./selfhost/d1-adapter";
import { loadFileSecrets } from "./selfhost/load-file-secrets";
import { peerRemoteAddress, withTrustedClientIp } from "./selfhost/trusted-client-ip";
import {
backupAcknowledgedGaugeValue,
buildHealthBody,
Expand Down Expand Up @@ -890,7 +892,14 @@ async function main(): Promise<void> {
const port = Number(process.env.PORT ?? 8787);
const server = serve(
{
fetch: async (request: Request) => {
fetch: async (request: Request, nodeEnv: HttpBindings | Http2Bindings) => {
// Self-host rate limiting keys off cf-connecting-ip (auth/rate-limit.ts). On Workers that header is
// edge-set; on Node it is attacker-controlled unless we overwrite it from the TCP peer / Caddy hop
// here (see trusted-client-ip.ts). Health/ready/metrics below still see the rewritten request.
// peerRemoteAddress reads the documented @hono/node-server HttpBindings/Http2Bindings shape
// (`incoming.socket.remoteAddress`) — covered by unit tests so a wrong field path cannot silently
// collapse every client into unknown-ip.
request = withTrustedClientIp(request, peerRemoteAddress(nodeEnv));
const path = new URL(request.url).pathname;
if (path === "/health")
return new Response(
Expand Down
144 changes: 144 additions & 0 deletions test/unit/trusted-client-ip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import {
isPrivateOrLinkLocal,
peerRemoteAddress,
resolveTrustedClientIp,
withTrustedClientIp,
} from "../../src/selfhost/trusted-client-ip";

describe("trusted-client-ip (self-host rate-limit identity)", () => {
it("REGRESSION: ignores client-spoofed cf-connecting-ip when the TCP peer is public (direct expose)", () => {
const headers = new Headers({
"cf-connecting-ip": "203.0.113.1",
"x-real-ip": "198.51.100.9",
"x-forwarded-for": "198.51.100.9",
});
// Public peer → use peer; proxy/CF headers are client-controlled on a direct :8787 path.
expect(resolveTrustedClientIp("203.0.113.50", headers)).toBe("203.0.113.50");
expect(resolveTrustedClientIp("203.0.113.51", headers)).toBe("203.0.113.51");
});

it("REGRESSION: behind a private proxy hop (Caddy), prefers X-Real-IP over a spoofed cf-connecting-ip", () => {
const headers = new Headers({
"cf-connecting-ip": "203.0.113.1",
"x-real-ip": "198.51.100.20",
"x-forwarded-for": "198.51.100.20",
});
expect(resolveTrustedClientIp("10.0.0.2", headers)).toBe("198.51.100.20");
expect(resolveTrustedClientIp("172.16.5.1", headers)).toBe("198.51.100.20");
expect(resolveTrustedClientIp("192.168.1.1", headers)).toBe("198.51.100.20");
});

it("falls back to leftmost X-Forwarded-For when X-Real-IP is absent behind a private hop", () => {
const headers = new Headers({
"cf-connecting-ip": "203.0.113.9",
"x-forwarded-for": "198.51.100.30, 10.0.0.2",
});
expect(resolveTrustedClientIp("10.0.0.2", headers)).toBe("198.51.100.30");
});

it("uses the private peer itself when Caddy headers are missing", () => {
expect(resolveTrustedClientIp("10.0.0.2", new Headers({ "cf-connecting-ip": "1.2.3.4" }))).toBe("10.0.0.2");
});

it("returns unknown-ip when no usable peer or proxy header is present", () => {
expect(resolveTrustedClientIp(undefined, new Headers({ "cf-connecting-ip": "203.0.113.1" }))).toBe("unknown-ip");
expect(resolveTrustedClientIp("not-an-ip", new Headers())).toBe("unknown-ip");
});

it("strips IPv4-mapped IPv6 peer prefixes", () => {
expect(resolveTrustedClientIp("::ffff:203.0.113.50", new Headers())).toBe("203.0.113.50");
expect(
resolveTrustedClientIp("::ffff:10.0.0.2", new Headers({ "x-real-ip": "198.51.100.40" })),
).toBe("198.51.100.40");
});

it("withTrustedClientIp deletes spoofed cf-connecting-ip and sets the trusted value", () => {
const original = new Request("https://orb.example/v1/auth/github/session", {
headers: {
"cf-connecting-ip": "203.0.113.1",
"x-real-ip": "198.51.100.55",
},
});
const trusted = withTrustedClientIp(original, "10.0.0.5");
expect(trusted.headers.get("cf-connecting-ip")).toBe("198.51.100.55");
expect(original.headers.get("cf-connecting-ip")).toBe("203.0.113.1");
});

it("withTrustedClientIp omits cf-connecting-ip when identity is unknown-ip", () => {
const trusted = withTrustedClientIp(
new Request("https://orb.example/health", { headers: { "cf-connecting-ip": "203.0.113.1" } }),
undefined,
);
expect(trusted.headers.get("cf-connecting-ip")).toBeNull();
});

it("peerRemoteAddress reads the documented HttpBindings/Http2Bindings socket path", () => {
expect(peerRemoteAddress({ incoming: { socket: { remoteAddress: "10.0.0.2" } } })).toBe("10.0.0.2");
expect(peerRemoteAddress({ incoming: { socket: { remoteAddress: "203.0.113.9" } } })).toBe("203.0.113.9");
expect(peerRemoteAddress({ incoming: { socket: null } })).toBeUndefined();
expect(peerRemoteAddress({ incoming: null })).toBeUndefined();
expect(peerRemoteAddress({})).toBeUndefined();
expect(peerRemoteAddress(null)).toBeUndefined();
expect(peerRemoteAddress(undefined)).toBeUndefined();
expect(peerRemoteAddress({ incoming: { socket: { remoteAddress: 123 } } })).toBeUndefined();
});

it("accepts bracketed IPv6 peers/headers and rejects malformed IPv4/IPv6", () => {
expect(
resolveTrustedClientIp("[2001:db8::1]", new Headers()),
).toBe("2001:db8::1");
expect(
resolveTrustedClientIp("10.0.0.2", new Headers({ "x-real-ip": "[2001:db8::abcd]" })),
).toBe("2001:db8::abcd");
expect(resolveTrustedClientIp("1.2.3", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("1.2.3.4.5", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("1.2.3.999", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("1.2.3.a", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("gggg::1", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("2001:db8::1::2", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("1:2:3:4:5:6:7:8:9", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("2001:db8::zzzz", new Headers())).toBe("unknown-ip");
// Segment longer than 4 hex digits passes the outer charset but fails per-segment validation.
expect(resolveTrustedClientIp("2001:db8::12345", new Headers())).toBe("unknown-ip");
// Dot-containing IPv6 form: outer charset allows `.`, per-segment hex check rejects.
expect(resolveTrustedClientIp("2001:db8::1.2", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("notaip", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp("[2001:db8::1", new Headers())).toBe("unknown-ip");
// Whitespace-only / trim edge cases.
expect(resolveTrustedClientIp(" ", new Headers())).toBe("unknown-ip");
expect(resolveTrustedClientIp(" 203.0.113.50 ", new Headers())).toBe("203.0.113.50");
});

it("classifies expanded loopback and unique-local IPv6 peers", () => {
expect(isPrivateOrLinkLocal("0:0:0:0:0:0:0:1")).toBe(true);
expect(isPrivateOrLinkLocal("FE80::1")).toBe(true);
expect(isPrivateOrLinkLocal("fd12::1")).toBe(true);
expect(isPrivateOrLinkLocal("not.an.ip.addr")).toBe(false);
expect(isPrivateOrLinkLocal("8.8.8.8")).toBe(false);
});

it("falls through XFF when X-Real-IP is present but invalid behind a private hop", () => {
expect(
resolveTrustedClientIp(
"10.0.0.2",
new Headers({ "x-real-ip": "not-an-ip", "x-forwarded-for": "198.51.100.77, 10.0.0.2" }),
),
).toBe("198.51.100.77");
});

it("classifies private / link-local / loopback peers", () => {
expect(isPrivateOrLinkLocal("10.1.2.3")).toBe(true);
expect(isPrivateOrLinkLocal("192.168.0.1")).toBe(true);
expect(isPrivateOrLinkLocal("172.16.0.1")).toBe(true);
expect(isPrivateOrLinkLocal("172.31.255.255")).toBe(true);
expect(isPrivateOrLinkLocal("127.0.0.1")).toBe(true);
expect(isPrivateOrLinkLocal("169.254.1.1")).toBe(true);
expect(isPrivateOrLinkLocal("::1")).toBe(true);
expect(isPrivateOrLinkLocal("fe80::1")).toBe(true);
expect(isPrivateOrLinkLocal("fc00::1")).toBe(true);
expect(isPrivateOrLinkLocal("203.0.113.1")).toBe(false);
expect(isPrivateOrLinkLocal("172.15.0.1")).toBe(false);
expect(isPrivateOrLinkLocal("172.32.0.1")).toBe(false);
});
});
Loading