mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 19:34:31 +00:00
fix(security): restrict canvas IP-based auth to private networks (#14661)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 9e4e1aca4a
Co-authored-by: sumleo <29517764+sumleo@users.noreply.github.com>
Co-authored-by: steipete <58493+steipete@users.noreply.github.com>
Reviewed-by: @steipete
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
### Fixes
|
### 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/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/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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { pickPrimaryLanIPv4, resolveGatewayListenHosts } from "./net.js";
|
import {
|
||||||
|
isPrivateOrLoopbackAddress,
|
||||||
|
pickPrimaryLanIPv4,
|
||||||
|
resolveGatewayListenHosts,
|
||||||
|
} from "./net.js";
|
||||||
|
|
||||||
describe("resolveGatewayListenHosts", () => {
|
describe("resolveGatewayListenHosts", () => {
|
||||||
it("returns the input host when not loopback", async () => {
|
it("returns the input host when not loopback", async () => {
|
||||||
@@ -77,3 +81,35 @@ describe("pickPrimaryLanIPv4", () => {
|
|||||||
expect(pickPrimaryLanIPv4()).toBeUndefined();
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -44,6 +44,50 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
|
|||||||
return false;
|
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 {
|
function normalizeIPv4MappedAddress(ip: string): string {
|
||||||
if (ip.startsWith("::ffff:")) {
|
if (ip.startsWith("::ffff:")) {
|
||||||
return ip.slice("::ffff:".length);
|
return ip.slice("::ffff:".length);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import {
|
|||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { sendGatewayAuthFailure } from "./http-common.js";
|
import { sendGatewayAuthFailure } from "./http-common.js";
|
||||||
import { getBearerToken, getHeader } from "./http-utils.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 { handleOpenAiHttpRequest } from "./openai-http.js";
|
||||||
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
|
||||||
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
||||||
@@ -143,6 +143,13 @@ async function authorizeCanvasRequest(params: {
|
|||||||
if (!clientIp) {
|
if (!clientIp) {
|
||||||
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
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)) {
|
if (hasAuthorizedWsClientForIp(clients, clientIp)) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ async function expectWsRejected(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway canvas host auth", () => {
|
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 = {
|
const resolvedAuth: ResolvedGatewayAuth = {
|
||||||
mode: "token",
|
mode: "token",
|
||||||
token: "test-token",
|
token: "test-token",
|
||||||
@@ -149,35 +149,37 @@ describe("gateway canvas host auth", () => {
|
|||||||
|
|
||||||
const listener = await listen(httpServer);
|
const listener = await listen(httpServer);
|
||||||
try {
|
try {
|
||||||
const ipA = "203.0.113.10";
|
const privateIpA = "192.168.1.10";
|
||||||
const ipB = "203.0.113.11";
|
const privateIpB = "192.168.1.11";
|
||||||
|
const publicIp = "203.0.113.10";
|
||||||
|
const cgnatIp = "100.100.100.100";
|
||||||
|
|
||||||
const unauthCanvas = await fetch(
|
const unauthCanvas = await fetch(
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
`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);
|
expect(unauthCanvas.status).toBe(401);
|
||||||
|
|
||||||
const unauthA2ui = await fetch(`http://127.0.0.1:${listener.port}${A2UI_PATH}/`, {
|
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);
|
expect(unauthA2ui.status).toBe(401);
|
||||||
|
|
||||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
||||||
"x-forwarded-for": ipA,
|
"x-forwarded-for": privateIpA,
|
||||||
});
|
});
|
||||||
|
|
||||||
clients.add({
|
clients.add({
|
||||||
socket: {} as unknown as WebSocket,
|
socket: {} as unknown as WebSocket,
|
||||||
connect: {} as never,
|
connect: {} as never,
|
||||||
connId: "c1",
|
connId: "c1",
|
||||||
clientIp: ipA,
|
clientIp: privateIpA,
|
||||||
});
|
});
|
||||||
|
|
||||||
const authCanvas = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
|
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(authCanvas.status).toBe(200);
|
||||||
expect(await authCanvas.text()).toBe("ok");
|
expect(await authCanvas.text()).toBe("ok");
|
||||||
@@ -185,14 +187,45 @@ describe("gateway canvas host auth", () => {
|
|||||||
const otherIpStillBlocked = await fetch(
|
const otherIpStillBlocked = await fetch(
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
`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);
|
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<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const ws = new WebSocket(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
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);
|
const timer = setTimeout(() => reject(new Error("timeout")), 10_000);
|
||||||
ws.once("open", () => {
|
ws.once("open", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user