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"],