mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 16:44:33 +00:00
fix(gateway): harden canvas auth with session capabilities
This commit is contained in:
@@ -4,18 +4,24 @@ import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui
|
||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||
import { createAuthRateLimiter } from "./auth-rate-limit.js";
|
||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||
import { CANVAS_CAPABILITY_PATH_PREFIX } from "./canvas-capability.js";
|
||||
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
import { withTempConfig } from "./test-temp-config.js";
|
||||
|
||||
async function listen(server: ReturnType<typeof createGatewayHttpServer>): Promise<{
|
||||
async function listen(
|
||||
server: ReturnType<typeof createGatewayHttpServer>,
|
||||
host = "127.0.0.1",
|
||||
): Promise<{
|
||||
host: string;
|
||||
port: number;
|
||||
close: () => Promise<void>;
|
||||
}> {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => server.listen(0, host, resolve));
|
||||
const addr = server.address();
|
||||
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
@@ -55,6 +61,8 @@ function makeWsClient(params: {
|
||||
clientIp: string;
|
||||
role: "node" | "operator";
|
||||
mode: "node" | "backend";
|
||||
canvasCapability?: string;
|
||||
canvasCapabilityExpiresAtMs?: number;
|
||||
}): GatewayWsClient {
|
||||
return {
|
||||
socket: {} as unknown as WebSocket,
|
||||
@@ -66,11 +74,18 @@ function makeWsClient(params: {
|
||||
} as GatewayWsClient["connect"],
|
||||
connId: params.connId,
|
||||
clientIp: params.clientIp,
|
||||
canvasCapability: params.canvasCapability,
|
||||
canvasCapabilityExpiresAtMs: params.canvasCapabilityExpiresAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function scopedCanvasPath(capability: string, path: string): string {
|
||||
return `${CANVAS_CAPABILITY_PATH_PREFIX}/${encodeURIComponent(capability)}${path}`;
|
||||
}
|
||||
|
||||
async function withCanvasGatewayHarness(params: {
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
listenHost?: string;
|
||||
rateLimiter?: ReturnType<typeof createAuthRateLimiter>;
|
||||
handleHttpRequest: CanvasHostHandler["handleHttpRequest"];
|
||||
run: (ctx: {
|
||||
@@ -117,7 +132,7 @@ async function withCanvasGatewayHarness(params: {
|
||||
rateLimiter: params.rateLimiter,
|
||||
});
|
||||
|
||||
const listener = await listen(httpServer);
|
||||
const listener = await listen(httpServer, params.listenHost);
|
||||
try {
|
||||
await params.run({ listener, clients });
|
||||
} finally {
|
||||
@@ -129,7 +144,7 @@ async function withCanvasGatewayHarness(params: {
|
||||
}
|
||||
|
||||
describe("gateway canvas host auth", () => {
|
||||
test("allows canvas IP fallback for private/CGNAT addresses and denies public fallback", async () => {
|
||||
test("authorizes canvas HTTP/WS via node-scoped capability and rejects misuse", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
@@ -161,110 +176,74 @@ describe("gateway canvas host auth", () => {
|
||||
return true;
|
||||
},
|
||||
run: async ({ listener, clients }) => {
|
||||
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 host = "127.0.0.1";
|
||||
const operatorOnlyCapability = "operator-only";
|
||||
const expiredNodeCapability = "expired-node";
|
||||
const activeNodeCapability = "active-node";
|
||||
const activeCanvasPath = scopedCanvasPath(activeNodeCapability, `${CANVAS_HOST_PATH}/`);
|
||||
const activeWsPath = scopedCanvasPath(activeNodeCapability, CANVAS_WS_PATH);
|
||||
|
||||
const unauthCanvas = await fetch(
|
||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||
{
|
||||
headers: { "x-forwarded-for": privateIpA },
|
||||
},
|
||||
);
|
||||
const unauthCanvas = await fetch(`http://${host}:${listener.port}${CANVAS_HOST_PATH}/`);
|
||||
expect(unauthCanvas.status).toBe(401);
|
||||
|
||||
const unauthA2ui = await fetch(`http://127.0.0.1:${listener.port}${A2UI_PATH}/`, {
|
||||
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": privateIpA,
|
||||
});
|
||||
const malformedScoped = await fetch(
|
||||
`http://${host}:${listener.port}${CANVAS_CAPABILITY_PATH_PREFIX}/broken`,
|
||||
);
|
||||
expect(malformedScoped.status).toBe(401);
|
||||
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-operator",
|
||||
clientIp: privateIpA,
|
||||
clientIp: "192.168.1.10",
|
||||
role: "operator",
|
||||
mode: "backend",
|
||||
canvasCapability: operatorOnlyCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const operatorCanvasStillBlocked = await fetch(
|
||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||
{
|
||||
headers: { "x-forwarded-for": privateIpA },
|
||||
},
|
||||
const operatorCapabilityBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(operatorOnlyCapability, `${CANVAS_HOST_PATH}/`)}`,
|
||||
);
|
||||
expect(operatorCanvasStillBlocked.status).toBe(401);
|
||||
expect(operatorCapabilityBlocked.status).toBe(401);
|
||||
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-node",
|
||||
clientIp: privateIpA,
|
||||
connId: "c-expired-node",
|
||||
clientIp: "192.168.1.20",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: expiredNodeCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() - 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const authCanvas = await fetch(
|
||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||
{
|
||||
headers: { "x-forwarded-for": privateIpA },
|
||||
},
|
||||
const expiredCapabilityBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(expiredNodeCapability, `${CANVAS_HOST_PATH}/`)}`,
|
||||
);
|
||||
expect(authCanvas.status).toBe(200);
|
||||
expect(await authCanvas.text()).toBe("ok");
|
||||
expect(expiredCapabilityBlocked.status).toBe(401);
|
||||
|
||||
const otherIpStillBlocked = await fetch(
|
||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||
{
|
||||
headers: { "x-forwarded-for": privateIpB },
|
||||
},
|
||||
);
|
||||
expect(otherIpStillBlocked.status).toBe(401);
|
||||
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-public",
|
||||
clientIp: publicIp,
|
||||
role: "node",
|
||||
mode: "node",
|
||||
}),
|
||||
);
|
||||
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,
|
||||
const activeNodeClient = makeWsClient({
|
||||
connId: "c-active-node",
|
||||
clientIp: "192.168.1.30",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: activeNodeCapability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
});
|
||||
clients.add(activeNodeClient);
|
||||
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-cgnat",
|
||||
clientIp: cgnatIp,
|
||||
role: "node",
|
||||
mode: "node",
|
||||
}),
|
||||
const scopedCanvas = await fetch(`http://${host}:${listener.port}${activeCanvasPath}`);
|
||||
expect(scopedCanvas.status).toBe(200);
|
||||
expect(await scopedCanvas.text()).toBe("ok");
|
||||
|
||||
const scopedA2ui = await fetch(
|
||||
`http://${host}:${listener.port}${scopedCanvasPath(activeNodeCapability, `${A2UI_PATH}/`)}`,
|
||||
);
|
||||
const cgnatAllowed = await fetch(
|
||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||
{
|
||||
headers: { "x-forwarded-for": cgnatIp },
|
||||
},
|
||||
);
|
||||
expect(cgnatAllowed.status).toBe(200);
|
||||
expect(scopedA2ui.status).toBe(200);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
||||
headers: { "x-forwarded-for": privateIpA },
|
||||
});
|
||||
const ws = new WebSocket(`ws://${host}:${listener.port}${activeWsPath}`);
|
||||
const timer = setTimeout(() => reject(new Error("timeout")), 10_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
@@ -277,13 +256,21 @@ describe("gateway canvas host auth", () => {
|
||||
});
|
||||
ws.once("error", reject);
|
||||
});
|
||||
|
||||
clients.delete(activeNodeClient);
|
||||
|
||||
const disconnectedNodeBlocked = await fetch(
|
||||
`http://${host}:${listener.port}${activeCanvasPath}`,
|
||||
);
|
||||
expect(disconnectedNodeBlocked.status).toBe(401);
|
||||
await expectWsRejected(`ws://${host}:${listener.port}${activeWsPath}`, {});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test("denies canvas IP fallback when proxy headers come from untrusted source", async () => {
|
||||
test("denies canvas auth when trusted proxy omits forwarded client headers", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
@@ -294,7 +281,7 @@ describe("gateway canvas host auth", () => {
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
gateway: {
|
||||
trustedProxies: [],
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
},
|
||||
},
|
||||
run: async () => {
|
||||
@@ -320,23 +307,98 @@ describe("gateway canvas host auth", () => {
|
||||
clientIp: "127.0.0.1",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: "unused",
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
|
||||
headers: { "x-forwarded-for": "192.168.1.10" },
|
||||
});
|
||||
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`);
|
||||
expect(res.status).toBe(401);
|
||||
|
||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
||||
"x-forwarded-for": "192.168.1.10",
|
||||
});
|
||||
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {});
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test("accepts capability-scoped paths over IPv6 loopback", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
token: "test-token",
|
||||
password: undefined,
|
||||
allowTailscale: false,
|
||||
};
|
||||
|
||||
await withTempConfig({
|
||||
cfg: {
|
||||
gateway: {
|
||||
trustedProxies: ["::1"],
|
||||
},
|
||||
},
|
||||
run: async () => {
|
||||
try {
|
||||
await withCanvasGatewayHarness({
|
||||
resolvedAuth,
|
||||
listenHost: "::1",
|
||||
handleHttpRequest: async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (
|
||||
url.pathname !== CANVAS_HOST_PATH &&
|
||||
!url.pathname.startsWith(`${CANVAS_HOST_PATH}/`)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
res.end("ok");
|
||||
return true;
|
||||
},
|
||||
run: async ({ listener, clients }) => {
|
||||
const capability = "ipv6-node";
|
||||
clients.add(
|
||||
makeWsClient({
|
||||
connId: "c-ipv6-node",
|
||||
clientIp: "fd12:3456:789a::2",
|
||||
role: "node",
|
||||
mode: "node",
|
||||
canvasCapability: capability,
|
||||
canvasCapabilityExpiresAtMs: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
|
||||
const canvasPath = scopedCanvasPath(capability, `${CANVAS_HOST_PATH}/`);
|
||||
const wsPath = scopedCanvasPath(capability, CANVAS_WS_PATH);
|
||||
const scopedCanvas = await fetch(`http://[::1]:${listener.port}${canvasPath}`);
|
||||
expect(scopedCanvas.status).toBe(200);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(`ws://[::1]:${listener.port}${wsPath}`);
|
||||
const timer = setTimeout(() => reject(new Error("timeout")), 10_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
ws.terminate();
|
||||
resolve();
|
||||
});
|
||||
ws.once("unexpected-response", (_req, res) => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`unexpected response ${res.statusCode}`));
|
||||
});
|
||||
ws.once("error", reject);
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
if (message.includes("EAFNOSUPPORT") || message.includes("EADDRNOTAVAIL")) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => {
|
||||
const resolvedAuth: ResolvedGatewayAuth = {
|
||||
mode: "token",
|
||||
|
||||
Reference in New Issue
Block a user