From 14fc7420001367b08fdf86da7cd03806b9b2a23b Mon Sep 17 00:00:00 2001 From: Yi Liu Date: Sat, 14 Feb 2026 00:13:31 +0800 Subject: [PATCH] fix(security): restrict canvas IP-based auth to private networks (#14661) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 9e4e1aca4a89fc4df572681a1146af31b1a3cd50 Co-authored-by: sumleo <29517764+sumleo@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete --- CHANGELOG.md | 1 + src/gateway/net.test.ts | 38 +++++++++++++++- src/gateway/net.ts | 44 ++++++++++++++++++ src/gateway/server-http.ts | 9 +++- src/gateway/server.canvas-auth.e2e.test.ts | 53 ++++++++++++++++++---- 5 files changed, 133 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c75c275de..b6c314ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane. +- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo. - Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane. - Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent. - Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck. diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 4d945e276..faa039abd 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -1,6 +1,10 @@ import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { pickPrimaryLanIPv4, resolveGatewayListenHosts } from "./net.js"; +import { + isPrivateOrLoopbackAddress, + pickPrimaryLanIPv4, + resolveGatewayListenHosts, +} from "./net.js"; describe("resolveGatewayListenHosts", () => { it("returns the input host when not loopback", async () => { @@ -77,3 +81,35 @@ describe("pickPrimaryLanIPv4", () => { expect(pickPrimaryLanIPv4()).toBeUndefined(); }); }); + +describe("isPrivateOrLoopbackAddress", () => { + it("accepts loopback, private, link-local, and cgnat ranges", () => { + const accepted = [ + "127.0.0.1", + "::1", + "10.1.2.3", + "172.16.0.1", + "172.31.255.254", + "192.168.0.1", + "169.254.10.20", + "100.64.0.1", + "100.127.255.254", + "::ffff:100.100.100.100", + "fc00::1", + "fd12:3456:789a::1", + "fe80::1", + "fe9a::1", + "febb::1", + ]; + for (const ip of accepted) { + expect(isPrivateOrLoopbackAddress(ip)).toBe(true); + } + }); + + it("rejects public addresses", () => { + const rejected = ["1.1.1.1", "8.8.8.8", "172.32.0.1", "203.0.113.10", "2001:4860:4860::8888"]; + for (const ip of rejected) { + expect(isPrivateOrLoopbackAddress(ip)).toBe(false); + } + }); +}); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 13c722054..aea973258 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -44,6 +44,50 @@ export function isLoopbackAddress(ip: string | undefined): boolean { return false; } +/** + * Returns true if the IP belongs to a private or loopback network range. + * Private ranges: RFC1918, link-local, ULA IPv6, and CGNAT (100.64/10), plus loopback. + */ +export function isPrivateOrLoopbackAddress(ip: string | undefined): boolean { + if (!ip) { + return false; + } + if (isLoopbackAddress(ip)) { + return true; + } + const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase()); + const family = net.isIP(normalized); + if (!family) { + return false; + } + + if (family === 4) { + const octets = normalized.split(".").map((value) => Number.parseInt(value, 10)); + if (octets.length !== 4 || octets.some((value) => Number.isNaN(value))) { + return false; + } + const [o1, o2] = octets; + // RFC1918 IPv4 private ranges. + if (o1 === 10 || (o1 === 172 && o2 >= 16 && o2 <= 31) || (o1 === 192 && o2 === 168)) { + return true; + } + // IPv4 link-local and CGNAT (commonly used by Tailnet-like networks). + if ((o1 === 169 && o2 === 254) || (o1 === 100 && o2 >= 64 && o2 <= 127)) { + return true; + } + return false; + } + + // IPv6 unique-local and link-local ranges. + if (normalized.startsWith("fc") || normalized.startsWith("fd")) { + return true; + } + if (/^fe[89ab]/.test(normalized)) { + return true; + } + return false; +} + function normalizeIPv4MappedAddress(ip: string): string { if (ip.startsWith("::ffff:")) { return ip.slice("::ffff:".length); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 9e57b1112..feb71a3ee 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -51,7 +51,7 @@ import { } from "./hooks.js"; import { sendGatewayAuthFailure } from "./http-common.js"; import { getBearerToken, getHeader } from "./http-utils.js"; -import { resolveGatewayClientIp } from "./net.js"; +import { isPrivateOrLoopbackAddress, resolveGatewayClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; @@ -143,6 +143,13 @@ async function authorizeCanvasRequest(params: { if (!clientIp) { return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; } + + // IP-based fallback is only safe for machine-scoped addresses. + // Only allow IP-based fallback for private/loopback addresses to prevent + // cross-session access in shared-IP environments (corporate NAT, cloud). + if (!isPrivateOrLoopbackAddress(clientIp)) { + return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; + } if (hasAuthorizedWsClientForIp(clients, clientIp)) { return { ok: true }; } diff --git a/src/gateway/server.canvas-auth.e2e.test.ts b/src/gateway/server.canvas-auth.e2e.test.ts index 2fd85442b..05a7d4145 100644 --- a/src/gateway/server.canvas-auth.e2e.test.ts +++ b/src/gateway/server.canvas-auth.e2e.test.ts @@ -81,7 +81,7 @@ async function expectWsRejected( } describe("gateway canvas host auth", () => { - test("authorizes canvas/a2ui HTTP and canvas WS by matching an authenticated gateway ws client ip", async () => { + test("allows canvas IP fallback for private/CGNAT addresses and denies public fallback", async () => { const resolvedAuth: ResolvedGatewayAuth = { mode: "token", token: "test-token", @@ -149,35 +149,37 @@ describe("gateway canvas host auth", () => { const listener = await listen(httpServer); try { - const ipA = "203.0.113.10"; - const ipB = "203.0.113.11"; + const privateIpA = "192.168.1.10"; + const privateIpB = "192.168.1.11"; + const publicIp = "203.0.113.10"; + const cgnatIp = "100.100.100.100"; const unauthCanvas = await fetch( `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { - headers: { "x-forwarded-for": ipA }, + headers: { "x-forwarded-for": privateIpA }, }, ); expect(unauthCanvas.status).toBe(401); const unauthA2ui = await fetch(`http://127.0.0.1:${listener.port}${A2UI_PATH}/`, { - headers: { "x-forwarded-for": ipA }, + headers: { "x-forwarded-for": privateIpA }, }); expect(unauthA2ui.status).toBe(401); await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, { - "x-forwarded-for": ipA, + "x-forwarded-for": privateIpA, }); clients.add({ socket: {} as unknown as WebSocket, connect: {} as never, connId: "c1", - clientIp: ipA, + clientIp: privateIpA, }); const authCanvas = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { - headers: { "x-forwarded-for": ipA }, + headers: { "x-forwarded-for": privateIpA }, }); expect(authCanvas.status).toBe(200); expect(await authCanvas.text()).toBe("ok"); @@ -185,14 +187,45 @@ describe("gateway canvas host auth", () => { const otherIpStillBlocked = await fetch( `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { - headers: { "x-forwarded-for": ipB }, + headers: { "x-forwarded-for": privateIpB }, }, ); expect(otherIpStillBlocked.status).toBe(401); + clients.add({ + socket: {} as unknown as WebSocket, + connect: {} as never, + connId: "c-public", + clientIp: publicIp, + }); + const publicIpStillBlocked = await fetch( + `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, + { + headers: { "x-forwarded-for": publicIp }, + }, + ); + expect(publicIpStillBlocked.status).toBe(401); + await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, { + "x-forwarded-for": publicIp, + }); + + clients.add({ + socket: {} as unknown as WebSocket, + connect: {} as never, + connId: "c-cgnat", + clientIp: cgnatIp, + }); + const cgnatAllowed = await fetch( + `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, + { + headers: { "x-forwarded-for": cgnatIp }, + }, + ); + expect(cgnatAllowed.status).toBe(200); + await new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, { - headers: { "x-forwarded-for": ipA }, + headers: { "x-forwarded-for": privateIpA }, }); const timer = setTimeout(() => reject(new Error("timeout")), 10_000); ws.once("open", () => {