diff --git a/docs/gateway/trusted-proxy-auth.md b/docs/gateway/trusted-proxy-auth.md index d82199b62686..a2e66f3bb11e 100644 --- a/docs/gateway/trusted-proxy-auth.md +++ b/docs/gateway/trusted-proxy-auth.md @@ -112,6 +112,7 @@ Internal Gateway clients that do not travel through the reverse proxy should use Array of proxy IP addresses (or CIDRs) to trust. Requests from other IPs are rejected. + IPv4 ranges may be written plainly (`10.0.0.0/8`) or in IPv4-mapped IPv6 form (`::ffff:10.0.0.0/104`); the two are equivalent, and both match a peer connecting as `10.1.2.3` or as `::ffff:10.1.2.3`. A mapped prefix counts the 96 leading mapped bits, so `::ffff:0:0/96` denotes all of IPv4 — including loopback, which still requires `gateway.auth.trustedProxy.allowLoopback`. Native IPv6 peers never match a mapped range. Must be `"trusted-proxy"`. diff --git a/packages/net-policy/src/ip.test.ts b/packages/net-policy/src/ip.test.ts index 5c742e35b4ec..c62eeed5b17e 100644 --- a/packages/net-policy/src/ip.test.ts +++ b/packages/net-policy/src/ip.test.ts @@ -52,6 +52,16 @@ describe("shared ip helpers", () => { ["fe80::1%eth0", "fe80::1%eth0", true], ["fe80::1%eth0", "fe80::1%eth1/128", true], ["::ffff:127.0.0.1", "::ffff:127.0.0.1/128", true], + ["10.1.2.3", "::ffff:10.0.0.0/104", true], + ["::ffff:10.1.2.3", "::ffff:10.0.0.0/104", true], + ["11.1.2.3", "::ffff:10.0.0.0/104", false], + ["10.42.0.59", "::ffff:10.42.0.0/120", true], + ["10.42.1.59", "::ffff:10.42.0.0/120", false], + ["10.0.0.1", "::ffff:10.0.0.0/128", false], + ["203.0.113.9", "::ffff:0:0/96", true], + ["::ffff:203.0.113.9", "::ffff:0:0/96", true], + ["203.0.113.9", "::ffff:10.0.0.0/64", true], + ["2001:db8::1", "::ffff:0:0/96", false], ])("matches %s against %s: %s", (ip, range, expected) => { expect(isIpInCidr(ip, range)).toBe(expected); }); diff --git a/packages/net-policy/src/ip.ts b/packages/net-policy/src/ip.ts index 1e39698a2647..86155d1cd305 100644 --- a/packages/net-policy/src/ip.ts +++ b/packages/net-policy/src/ip.ts @@ -412,7 +412,12 @@ export function isIpInCidr(ip: string, cidr: string): boolean { ); } if (isIpv4Address(comparableIp) && isIpv4Address(comparableBase)) { - return comparableIp.match([comparableBase, prefixLength]); + // A base normalized from IPv6 is mapped: its prefix includes 96 mapped bits. + // Shorter prefixes contain the whole mapped block, equivalent to IPv4 /0. + const ipv4PrefixLength = isIpv6Address(baseAddress) + ? Math.max(0, prefixLength - 96) + : prefixLength; + return comparableIp.match([comparableBase, ipv4PrefixLength]); } if (isIpv6Address(comparableIp) && isIpv6Address(comparableBase)) { return comparableIp.match([comparableBase, prefixLength]); diff --git a/src/commands/configure.gateway.test.ts b/src/commands/configure.gateway.test.ts index ac618ed97ffc..9fa9e6d28d29 100644 --- a/src/commands/configure.gateway.test.ts +++ b/src/commands/configure.gateway.test.ts @@ -258,6 +258,7 @@ describe("promptGatewayConfig", () => { ["::/127", "::1"], ["::/0", "::1"], ["::ffff:127.0.0.2/128", "127.0.0.2"], + ["::ffff:127.0.0.0/104", "127.0.0.1"], [" 127.0.0.1 , \t::1/128 ", "::1"], ])("accepts runtime auth after consent for loopback proxy %s", async (proxies, remoteAddress) => { vi.stubEnv("OPENCLAW_LOCALE", "en"); @@ -282,7 +283,25 @@ describe("promptGatewayConfig", () => { }); }); - it.each(["126.0.0.0/8", "::/128", "::2/127", "::ffff:0:0/96", "::1%LO0"])( + it.each(["0.0.0.0/0", "::ffff:0:0/96"])( + "asks for loopback consent for the IPv4 catch-all %s but cannot attribute forwarded clients through it", + async (proxies) => { + const result = await runTrustedProxyPrompt({ + textQueue: ["18789", "x-forwarded-user", "", "", proxies], + confirmResult: true, + }); + expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false })); + expect(result.config.gateway?.auth?.trustedProxy?.allowLoopback).toBe(true); + expect(isTrustedProxyAddress("127.0.0.1", result.config.gateway?.trustedProxies)).toBe(true); + // Every IPv4 hop is trusted, so the forwarded client address is consumed as a proxy too. + expect(await authorizeConfiguredProxy(result.config, "127.0.0.1")).toEqual({ + ok: false, + reason: "proxy_attribution_required", + }); + }, + ); + + it.each(["126.0.0.0/8", "::/128", "::2/127", "::ffff:126.0.0.0/104", "::1%LO0"])( "does not ask for loopback consent when runtime cannot match loopback through %s", async (proxies) => { const result = await runTrustedProxyPrompt({ diff --git a/src/gateway/ingress-attribution.test.ts b/src/gateway/ingress-attribution.test.ts index 3615904c4127..0ff743a82aee 100644 --- a/src/gateway/ingress-attribution.test.ts +++ b/src/gateway/ingress-attribution.test.ts @@ -69,6 +69,37 @@ describe("gateway ingress attribution", () => { }, ); + it.each([ + ["an IPv4-mapped CIDR", "::ffff:10.0.0.0/104"], + ["its equivalent plain IPv4 CIDR", "10.0.0.0/8"], + ])( + "attributes the forwarded client through a proxy trusted by %s", + async (_name, trustedProxy) => { + const attribution = prepareGatewayIngressAttribution({ + req: request({ remoteAddress: "10.1.2.3", forwardedFor: "203.0.113.9" }), + trustedProxies: [trustedProxy], + }); + + expect(attribution).toMatchObject({ + kind: "trusted-proxy", + clientIp: "203.0.113.9", + rateLimit: { subject: { key: "203.0.113.9" } }, + }); + }, + ); + + it("rejects a proxy that falls outside an IPv4-mapped trusted range", async () => { + const attribution = prepareGatewayIngressAttribution({ + req: request({ remoteAddress: "11.1.2.3", forwardedFor: "203.0.113.9" }), + trustedProxies: ["::ffff:10.0.0.0/104"], + }); + + expect(attribution).toMatchObject({ + kind: "unattributable-proxy", + reason: "proxy_attribution_required", + }); + }); + it.each([ ["missing", undefined], ["loopback", "127.0.0.1"],