From 9d87caebe95ec43f576db2739e05aeb5b2c73840 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 16 Jul 2026 21:04:01 +0800 Subject: [PATCH 1/7] fix: resolve self-host rate-limit IP spoof via cf-connecting-ip Co-authored-by: Cursor --- src/selfhost/cf-workers-shim.ts | 8 +-- src/selfhost/trusted-client-ip.ts | 96 +++++++++++++++++++++++++++++ src/server.ts | 7 ++- test/unit/trusted-client-ip.test.ts | 89 ++++++++++++++++++++++++++ 4 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 src/selfhost/trusted-client-ip.ts create mode 100644 test/unit/trusted-client-ip.test.ts diff --git a/src/selfhost/cf-workers-shim.ts b/src/selfhost/cf-workers-shim.ts index 4307219150..1a066a47d0 100644 --- a/src/selfhost/cf-workers-shim.ts +++ b/src/selfhost/cf-workers-shim.ts @@ -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 { constructor( protected ctx?: unknown, diff --git a/src/selfhost/trusted-client-ip.ts b/src/selfhost/trusted-client-ip.ts new file mode 100644 index 0000000000..c85c6edc88 --- /dev/null +++ b/src/selfhost/trusted-client-ip.ts @@ -0,0 +1,96 @@ +// 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"; +} + +/** 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; +} diff --git a/src/server.ts b/src/server.ts index 76554442d5..ed9a8e5f4c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { withTrustedClientIp } from "./selfhost/trusted-client-ip"; import { backupAcknowledgedGaugeValue, buildHealthBody, @@ -890,7 +891,11 @@ async function main(): Promise { const port = Number(process.env.PORT ?? 8787); const server = serve( { - fetch: async (request: Request) => { + fetch: async (request: Request, nodeEnv?: { incoming?: { socket?: { remoteAddress?: string } } }) => { + // 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. + request = withTrustedClientIp(request, nodeEnv?.incoming?.socket?.remoteAddress); const path = new URL(request.url).pathname; if (path === "/health") return new Response( diff --git a/test/unit/trusted-client-ip.test.ts b/test/unit/trusted-client-ip.test.ts new file mode 100644 index 0000000000..a4b235b9ea --- /dev/null +++ b/test/unit/trusted-client-ip.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + isPrivateOrLinkLocal, + 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("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); + }); +}); From e2c4fc5ab464170f8f9f25b14c71ace2b4817849 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 16 Jul 2026 21:18:23 +0800 Subject: [PATCH 2/7] fix: type self-host fetch handler as HttpBindings for node-server Co-authored-by: Cursor --- src/server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index ed9a8e5f4c..aa85a4453d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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 { HttpBindings } from "@hono/node-server"; import packageJson from "../package.json"; import worker from "./index"; import { githubRestRateLimitRemainingSamples } from "./github/client"; @@ -891,11 +892,11 @@ async function main(): Promise { const port = Number(process.env.PORT ?? 8787); const server = serve( { - fetch: async (request: Request, nodeEnv?: { incoming?: { socket?: { remoteAddress?: string } } }) => { + fetch: async (request: Request, nodeEnv: HttpBindings) => { // 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. - request = withTrustedClientIp(request, nodeEnv?.incoming?.socket?.remoteAddress); + request = withTrustedClientIp(request, nodeEnv.incoming.socket?.remoteAddress); const path = new URL(request.url).pathname; if (path === "/health") return new Response( From b4602788fbcc292265c4c2a4b6b4cf3388759416 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 16 Jul 2026 21:23:46 +0800 Subject: [PATCH 3/7] fix: harden peer IP extraction typing for node-server HttpBindings Co-authored-by: Cursor --- src/selfhost/trusted-client-ip.ts | 13 +++++++++++++ src/server.ts | 11 +++++++---- test/unit/trusted-client-ip.test.ts | 12 ++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/selfhost/trusted-client-ip.ts b/src/selfhost/trusted-client-ip.ts index c85c6edc88..e6dbdd511f 100644 --- a/src/selfhost/trusted-client-ip.ts +++ b/src/selfhost/trusted-client-ip.ts @@ -25,6 +25,19 @@ export function resolveTrustedClientIp( 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); diff --git a/src/server.ts b/src/server.ts index aa85a4453d..6b7f55b8da 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +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 { HttpBindings } 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"; @@ -45,7 +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 { withTrustedClientIp } from "./selfhost/trusted-client-ip"; +import { peerRemoteAddress, withTrustedClientIp } from "./selfhost/trusted-client-ip"; import { backupAcknowledgedGaugeValue, buildHealthBody, @@ -892,11 +892,14 @@ async function main(): Promise { const port = Number(process.env.PORT ?? 8787); const server = serve( { - fetch: async (request: Request, nodeEnv: HttpBindings) => { + 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. - request = withTrustedClientIp(request, nodeEnv.incoming.socket?.remoteAddress); + // 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( diff --git a/test/unit/trusted-client-ip.test.ts b/test/unit/trusted-client-ip.test.ts index a4b235b9ea..e1b0ecc0ff 100644 --- a/test/unit/trusted-client-ip.test.ts +++ b/test/unit/trusted-client-ip.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { isPrivateOrLinkLocal, + peerRemoteAddress, resolveTrustedClientIp, withTrustedClientIp, } from "../../src/selfhost/trusted-client-ip"; @@ -72,6 +73,17 @@ describe("trusted-client-ip (self-host rate-limit identity)", () => { 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("classifies private / link-local / loopback peers", () => { expect(isPrivateOrLinkLocal("10.1.2.3")).toBe(true); expect(isPrivateOrLinkLocal("192.168.0.1")).toBe(true); From 90a49fa7f2051bb38cfe41695f12c13f8610038d Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 16 Jul 2026 21:25:10 +0800 Subject: [PATCH 4/7] chore: retrigger CI after force-push race Co-authored-by: Cursor From e78cff6852d5a3c7ae49f59ad61538399b199383 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 16 Jul 2026 21:38:03 +0800 Subject: [PATCH 5/7] test(selfhost): cover IPv6/malformed IP branches for trusted-client-ip patch Co-authored-by: Cursor --- test/unit/trusted-client-ip.test.ts | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/test/unit/trusted-client-ip.test.ts b/test/unit/trusted-client-ip.test.ts index e1b0ecc0ff..78ef5addbc 100644 --- a/test/unit/trusted-client-ip.test.ts +++ b/test/unit/trusted-client-ip.test.ts @@ -84,6 +84,41 @@ describe("trusted-client-ip (self-host rate-limit identity)", () => { 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", 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("notaip", new Headers())).toBe("unknown-ip"); + }); + + 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); From 69c70f47422634d7feab5dc956e1ac5fd3871053 Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 16 Jul 2026 21:38:16 +0800 Subject: [PATCH 6/7] test(selfhost): fix invalid IPv6 fixture for trusted-client-ip coverage Co-authored-by: Cursor --- test/unit/trusted-client-ip.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/unit/trusted-client-ip.test.ts b/test/unit/trusted-client-ip.test.ts index 78ef5addbc..dc03ee966e 100644 --- a/test/unit/trusted-client-ip.test.ts +++ b/test/unit/trusted-client-ip.test.ts @@ -96,10 +96,11 @@ describe("trusted-client-ip (self-host rate-limit identity)", () => { 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", 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("notaip", new Headers())).toBe("unknown-ip"); + expect(resolveTrustedClientIp("[2001:db8::1", new Headers())).toBe("unknown-ip"); }); it("classifies expanded loopback and unique-local IPv6 peers", () => { From dd36ad809b2fc50f8dd3f691ed1e1a2ec703a39c Mon Sep 17 00:00:00 2001 From: RealDiligent Date: Thu, 16 Jul 2026 21:45:16 +0800 Subject: [PATCH 7/7] test(selfhost): hit IPv6 per-segment reject branch for codecov patch Co-authored-by: Cursor --- test/unit/trusted-client-ip.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/unit/trusted-client-ip.test.ts b/test/unit/trusted-client-ip.test.ts index dc03ee966e..0cf8d6dfe8 100644 --- a/test/unit/trusted-client-ip.test.ts +++ b/test/unit/trusted-client-ip.test.ts @@ -99,8 +99,15 @@ describe("trusted-client-ip (self-host rate-limit identity)", () => { 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", () => {