Skip to content
Closed
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
95 changes: 95 additions & 0 deletions src/selfhost/trusted-client-ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// Resolve the real client IP for self-host rate limiting (#477).
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";
}

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;
}

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 });
}

export function applySelfHostFetchTrustedClientIp(request: Request, nodeEnv: unknown): Request {
return withTrustedClientIp(request, peerRemoteAddress(nodeEnv));
}

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;
}

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;
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;
}
12 changes: 10 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { existsSync, readdirSync, writeFileSync } from "node:fs";
import { delimiter, join } from "node:path";
import { randomUUID } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
import { serve } from "@hono/node-server";
import { serve, type Http2Bindings, type 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 +44,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 { applySelfHostFetchTrustedClientIp } from "./selfhost/trusted-client-ip";
import {
backupAcknowledgedGaugeValue,
buildHealthBody,
Expand Down Expand Up @@ -893,7 +894,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 = applySelfHostFetchTrustedClientIp(request, nodeEnv);
const path = new URL(request.url).pathname;
if (path === "/health")
return new Response(
Expand Down
152 changes: 152 additions & 0 deletions test/unit/trusted-client-ip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
applySelfHostFetchTrustedClientIp,
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",
});
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("applySelfHostFetchTrustedClientIp mirrors the server.ts serve({ fetch }) edge wiring", () => {
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.88",
},
});
const trusted = applySelfHostFetchTrustedClientIp(original, {
incoming: { socket: { remoteAddress: "10.0.0.5" } },
});
expect(trusted.headers.get("cf-connecting-ip")).toBe("198.51.100.88");
});

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");
expect(resolveTrustedClientIp("2001:db8::12345", new Headers())).toBe("unknown-ip");
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");
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