mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(gateway): honor mapped IPv4 CIDR ranges (#137668)
Closes #137665
Related: #131475
## What Problem This Solves
Fixes an issue where an accepted IPv4-mapped CIDR such as `::ffff:10.0.0.0/104` rejected proxy addresses that belong to the equivalent `10.0.0.0/8` range. Proxied requests returned HTTP 403 `proxy_attribution_required`. The same matcher left eligible node pairing requests pending for mapped `autoApproveCidrs` and `sshVerify.cidrs` entries.
## Why This Change Was Made
Normalize the prefix into IPv4's bit space before the existing IPv4 match. The shared matcher now handles plain and mapped ranges in one path, without a second mapped-address matcher. Exact addresses, native IPv6, parsing, and downstream authorization keep their existing paths.
## User Impact
Mapped `/104`, `/120`, and `/128` ranges behave like IPv4 `/8`, `/24`, and exact addresses. Prefixes at or below `/96` cover all IPv4, including loopback; the wizard keeps its explicit loopback-consent prompt. Native IPv6 peers do not match mapped ranges. Keep proxy allowlists narrow: a catch-all still consumes every IPv4 forwarded hop and cannot attribute a client through that chain.
Automatic pairing still requires a fresh, signed, scopeless node request. Roles, scopes, browser requests, manual approval, and SSH host/device identity checks retain their own gates. No configuration field, schema, dependency, or permission policy is added.
## Evidence
Real source Gateway and CLI runs use isolated documentation-address peers, a real overwriting loopback reverse proxy, TLS, and actual SSH reading `openclaw node identity --json`.
| Caller | Pinned main | Repair |
| --- | --- | --- |
| Matching mapped proxy range | HTTP 403 | HTTP 200 from protected `/v1/models` |
| Plain equivalent proxy range | HTTP 200 | HTTP 200 |
| Nearby nonmatching proxy range | HTTP 403 | HTTP 403 |
| Matching mapped node auto-approval range | Manual pending | Approved via trusted CIDR and connected |
| Matching mapped SSH-verification range | Manual pending | SSH verified and connected |
Additional real controls cover `/64`, `/95`, `/96`, `/104`, `/128`, exact nonmatches, native IPv6, loopback consent, missing/disallowed identities, missing headers, invalid/loopback forwarded clients, origin allow/deny, scope caps, and SSH identity/key failures. Signed WebSocket checks retain manual role upgrades, scoped/browser requests, signature rejection, and manual read-only enrollment. The enrolled operator can read after restart and cannot invoke an administrative write. CIDR device pairing retains separate capability approval.
The three focused regression files show nine intended failures on main and 123 passes with the repair. Another 232 focused network/pairing/SSH tests pass. The exact installed ipaddr.js 2.5.0 source was inspected: IPv4 matching consumes 32 bits, so mapped prefixes must subtract 96.
Paired runtime proof uses main `1a7224b69f` plus this repair, preserving newer Gateway callers. The changed matcher has exact byte identity with the candidate; the old contributor branch retains its ancestry. The contributor-based tree separately passes its own runtime build, formatter check, and 120 focused tests with its pinned dependencies. An independent validator completed 47 fresh runtime cases without an observed defect; source, historical, wizard-presentation, broader-policy and cleanup observations are assessed separately rather than claimed as independent passes. Historical setup and probe-assumption corrections remain recorded privately.
AI-assisted.
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
parent
253f76a882
commit
7069a4772f
5 changed files with 68 additions and 2 deletions
|
|
@ -112,6 +112,7 @@ Internal Gateway clients that do not travel through the reverse proxy should use
|
|||
|
||||
<ParamField path="gateway.trustedProxies" type="string[]" required>
|
||||
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.
|
||||
</ParamField>
|
||||
<ParamField path="gateway.auth.mode" type="string" required>
|
||||
Must be `"trusted-proxy"`.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue