From a459e237e843c18a1170a215214659a593370968 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Thu, 5 Feb 2026 16:22:34 -0800 Subject: [PATCH] fix(gateway): require auth for canvas host and a2ui assets (#9518) (thanks @coygeek) --- CHANGELOG.md | 1 + src/agents/pi-tools.safe-bins.test.ts | 25 ++- src/agents/pi-tools.workspace-paths.test.ts | 1 - src/cli/program.smoke.test.ts | 2 + src/gateway/server-http.ts | 97 +++++--- src/gateway/server-runtime-state.ts | 7 +- src/gateway/server.canvas-auth.e2e.test.ts | 212 ++++++++++++++++++ .../server/ws-connection/message-handler.ts | 1 + src/gateway/server/ws-types.ts | 1 + 9 files changed, 314 insertions(+), 33 deletions(-) create mode 100644 src/gateway/server.canvas-auth.e2e.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46cfb1f8c..a65337224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg. - Telegram: preserve DM topic threadId in deliveryContext. (#9039) Thanks @lailoo. - macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety. +- Security: require gateway auth for Canvas host and A2UI assets. (#9518) Thanks @coygeek. ## 2026.2.2-3 diff --git a/src/agents/pi-tools.safe-bins.test.ts b/src/agents/pi-tools.safe-bins.test.ts index 51af7ee0c..20c2a87eb 100644 --- a/src/agents/pi-tools.safe-bins.test.ts +++ b/src/agents/pi-tools.safe-bins.test.ts @@ -41,6 +41,11 @@ vi.mock("../infra/shell-env.js", async (importOriginal) => { return { ...mod, getShellPathFromLoginShell: () => null }; }); +vi.mock("../plugins/tools.js", () => ({ + resolvePluginTools: () => [], + getPluginToolMeta: () => undefined, +})); + vi.mock("../infra/exec-approvals.js", async (importOriginal) => { const mod = await importOriginal(); const approvals: ExecApprovalsResolved = { @@ -104,10 +109,22 @@ describe("createOpenClawCodingTools safeBins", () => { expect(execTool).toBeDefined(); const marker = `safe-bins-${Date.now()}`; - const result = await execTool!.execute("call1", { - command: `echo ${marker}`, - workdir: tmpDir, - }); + const prevShellEnvTimeoutMs = process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS; + process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000"; + const result = await (async () => { + try { + return await execTool!.execute("call1", { + command: `echo ${marker}`, + workdir: tmpDir, + }); + } finally { + if (prevShellEnvTimeoutMs === undefined) { + delete process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS; + } else { + process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = prevShellEnvTimeoutMs; + } + } + })(); const text = result.content.find((content) => content.type === "text")?.text ?? ""; expect(result.details.status).toBe("completed"); diff --git a/src/agents/pi-tools.workspace-paths.test.ts b/src/agents/pi-tools.workspace-paths.test.ts index b7d9e6d31..320bd7f93 100644 --- a/src/agents/pi-tools.workspace-paths.test.ts +++ b/src/agents/pi-tools.workspace-paths.test.ts @@ -13,7 +13,6 @@ vi.mock("../infra/shell-env.js", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, getShellPathFromLoginShell: () => null }; }); - async function withTempDir(prefix: string, fn: (dir: string) => Promise) { const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); try { diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 23ff74006..28e100e1e 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -22,6 +22,8 @@ const runtime = { }), }; +vi.mock("./plugin-registry.js", () => ({ ensurePluginRegistryLoaded: () => undefined })); + vi.mock("../commands/message.js", () => ({ messageCommand })); vi.mock("../commands/status.js", () => ({ statusCommand })); vi.mock("../commands/configure.js", () => ({ diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 39548108d..8e63deecf 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -9,6 +9,7 @@ import { import { createServer as createHttpsServer } from "node:https"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { A2UI_PATH, @@ -18,7 +19,7 @@ import { } from "../canvas-host/a2ui.js"; import { loadConfig } from "../config/config.js"; import { handleSlackHttpRequest } from "../slack/http/index.js"; -import { authorizeGatewayConnect } from "./auth.js"; +import { authorizeGatewayConnect, isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js"; import { handleControlUiAvatarRequest, handleControlUiHttpRequest, @@ -38,7 +39,8 @@ import { resolveHookDeliver, } from "./hooks.js"; import { sendUnauthorized } from "./http-common.js"; -import { getBearerToken } from "./http-utils.js"; +import { getBearerToken, getHeader } from "./http-utils.js"; +import { resolveGatewayClientIp } from "./net.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js"; @@ -78,6 +80,51 @@ function isCanvasPath(pathname: string): boolean { ); } +function hasAuthorizedWsClientForIp(clients: Set, clientIp: string): boolean { + for (const client of clients) { + if (client.clientIp && client.clientIp === clientIp) { + return true; + } + } + return false; +} + +async function authorizeCanvasRequest(params: { + req: IncomingMessage; + auth: ResolvedGatewayAuth; + trustedProxies: string[]; + clients: Set; +}): Promise { + const { req, auth, trustedProxies, clients } = params; + if (isLocalDirectRequest(req, trustedProxies)) { + return true; + } + + const token = getBearerToken(req); + if (token) { + const authResult = await authorizeGatewayConnect({ + auth: { ...auth, allowTailscale: false }, + connectAuth: { token, password: token }, + req, + trustedProxies, + }); + if (authResult.ok) { + return true; + } + } + + const clientIp = resolveGatewayClientIp({ + remoteAddr: req.socket?.remoteAddress ?? "", + forwardedFor: getHeader(req, "x-forwarded-for"), + realIp: getHeader(req, "x-real-ip"), + trustedProxies, + }); + if (!clientIp) { + return false; + } + return hasAuthorizedWsClientForIp(clients, clientIp); +} + export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise; export function createHooksRequestHandler( @@ -226,6 +273,7 @@ export function createHooksRequestHandler( export function createGatewayHttpServer(opts: { canvasHost: CanvasHostHandler | null; + clients: Set; controlUiEnabled: boolean; controlUiBasePath: string; controlUiRoot?: ControlUiRootState; @@ -234,11 +282,12 @@ export function createGatewayHttpServer(opts: { openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig; handleHooksRequest: HooksRequestHandler; handlePluginRequest?: HooksRequestHandler; - resolvedAuth: import("./auth.js").ResolvedGatewayAuth; + resolvedAuth: ResolvedGatewayAuth; tlsOptions?: TlsOptions; }): HttpServer { const { canvasHost, + clients, controlUiEnabled, controlUiBasePath, controlUiRoot, @@ -305,16 +354,15 @@ export function createGatewayHttpServer(opts: { } } if (canvasHost) { - const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const url = new URL(req.url ?? "/", "http://localhost"); if (isCanvasPath(url.pathname)) { - const token = getBearerToken(req); - const authResult = await authorizeGatewayConnect({ - auth: resolvedAuth, - connectAuth: token ? { token, password: token } : null, + const ok = await authorizeCanvasRequest({ req, + auth: resolvedAuth, trustedProxies, + clients, }); - if (!authResult.ok) { + if (!ok) { sendUnauthorized(res); return; } @@ -363,41 +411,38 @@ export function attachGatewayUpgradeHandler(opts: { httpServer: HttpServer; wss: WebSocketServer; canvasHost: CanvasHostHandler | null; - resolvedAuth: import("./auth.js").ResolvedGatewayAuth; + clients: Set; + resolvedAuth: ResolvedGatewayAuth; }) { - const { httpServer, wss, canvasHost, resolvedAuth } = opts; + const { httpServer, wss, canvasHost, clients, resolvedAuth } = opts; httpServer.on("upgrade", (req, socket, head) => { void (async () => { if (canvasHost) { - const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname === CANVAS_WS_PATH) { const configSnapshot = loadConfig(); - const token = getBearerToken(req); - const authResult = await authorizeGatewayConnect({ - auth: resolvedAuth, - connectAuth: token ? { token, password: token } : null, + const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; + const ok = await authorizeCanvasRequest({ req, - trustedProxies: configSnapshot.gateway?.trustedProxies ?? [], + auth: resolvedAuth, + trustedProxies, + clients, }); - if (!authResult.ok) { + if (!ok) { socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); socket.destroy(); return; } } - } - if (canvasHost?.handleUpgrade(req, socket, head)) { - return; + if (canvasHost.handleUpgrade(req, socket, head)) { + return; + } } wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); }); })().catch(() => { - try { - socket.destroy(); - } catch { - // ignore - } + socket.destroy(); }); }); } diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index f0282af50..0312fc2e1 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -107,6 +107,9 @@ export async function createGatewayRuntimeState(params: { } } + const clients = new Set(); + const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients }); + const handleHooksRequest = createGatewayHooksRequestHandler({ deps: params.deps, getHooksConfig: params.hooksConfig, @@ -126,6 +129,7 @@ export async function createGatewayRuntimeState(params: { for (const host of bindHosts) { const httpServer = createGatewayHttpServer({ canvasHost, + clients, controlUiEnabled: params.controlUiEnabled, controlUiBasePath: params.controlUiBasePath, controlUiRoot: params.controlUiRoot, @@ -168,12 +172,11 @@ export async function createGatewayRuntimeState(params: { httpServer: server, wss, canvasHost, + clients, resolvedAuth: params.resolvedAuth, }); } - const clients = new Set(); - const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients }); const agentRunSeq = new Map(); const dedupe = new Map(); const chatRunState = createChatRunState(); diff --git a/src/gateway/server.canvas-auth.e2e.test.ts b/src/gateway/server.canvas-auth.e2e.test.ts new file mode 100644 index 000000000..6fd85ac94 --- /dev/null +++ b/src/gateway/server.canvas-auth.e2e.test.ts @@ -0,0 +1,212 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { WebSocket, WebSocketServer } from "ws"; +import type { CanvasHostHandler } from "../canvas-host/server.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import type { GatewayWsClient } from "./server/ws-types.js"; +import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui.js"; +import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js"; + +async function withTempConfig(params: { cfg: unknown; run: () => Promise }): Promise { + const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH; + const prevDisableCache = process.env.OPENCLAW_DISABLE_CONFIG_CACHE; + + const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-auth-test-")); + const configPath = path.join(dir, "openclaw.json"); + + process.env.OPENCLAW_CONFIG_PATH = configPath; + process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1"; + + try { + await writeFile(configPath, JSON.stringify(params.cfg, null, 2), "utf-8"); + await params.run(); + } finally { + if (prevConfigPath === undefined) { + delete process.env.OPENCLAW_CONFIG_PATH; + } else { + process.env.OPENCLAW_CONFIG_PATH = prevConfigPath; + } + if (prevDisableCache === undefined) { + delete process.env.OPENCLAW_DISABLE_CONFIG_CACHE; + } else { + process.env.OPENCLAW_DISABLE_CONFIG_CACHE = prevDisableCache; + } + await rm(dir, { recursive: true, force: true }); + } +} + +async function listen(server: ReturnType): Promise<{ + port: number; + close: () => Promise; +}> { + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + return { + port, + close: async () => { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + }, + }; +} + +async function expectWsRejected(url: string, headers: Record): Promise { + await new Promise((resolve, reject) => { + const ws = new WebSocket(url, { headers }); + const timer = setTimeout(() => reject(new Error("timeout")), 10_000); + ws.once("open", () => { + clearTimeout(timer); + ws.terminate(); + reject(new Error("expected ws to reject")); + }); + ws.once("unexpected-response", (_req, res) => { + clearTimeout(timer); + expect(res.statusCode).toBe(401); + resolve(); + }); + ws.once("error", () => { + clearTimeout(timer); + resolve(); + }); + }); +} + +describe("gateway canvas host auth", () => { + test("authorizes canvas/a2ui HTTP and canvas WS by matching an authenticated gateway ws client ip", async () => { + const resolvedAuth: ResolvedGatewayAuth = { + mode: "token", + token: "test-token", + password: undefined, + allowTailscale: false, + }; + + await withTempConfig({ + cfg: { + gateway: { + trustedProxies: ["127.0.0.1"], + }, + }, + run: async () => { + const clients = new Set(); + + const canvasWss = new WebSocketServer({ noServer: true }); + const canvasHost: CanvasHostHandler = { + rootDir: "test", + close: async () => {}, + handleUpgrade: (req, socket, head) => { + const url = new URL(req.url ?? "/", "http://localhost"); + if (url.pathname !== CANVAS_WS_PATH) { + return false; + } + canvasWss.handleUpgrade(req, socket, head, (ws) => { + ws.close(); + }); + return true; + }, + 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; + }, + }; + + const httpServer = createGatewayHttpServer({ + canvasHost, + clients, + controlUiEnabled: false, + controlUiBasePath: "/__control__", + openAiChatCompletionsEnabled: false, + openResponsesEnabled: false, + handleHooksRequest: async () => false, + resolvedAuth, + }); + + const wss = new WebSocketServer({ noServer: true }); + attachGatewayUpgradeHandler({ + httpServer, + wss, + canvasHost, + clients, + resolvedAuth, + }); + + const listener = await listen(httpServer); + try { + const ipA = "203.0.113.10"; + const ipB = "203.0.113.11"; + + const unauthCanvas = await fetch( + `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, + { + headers: { "x-forwarded-for": ipA }, + }, + ); + expect(unauthCanvas.status).toBe(401); + + const unauthA2ui = await fetch(`http://127.0.0.1:${listener.port}${A2UI_PATH}/`, { + headers: { "x-forwarded-for": ipA }, + }); + expect(unauthA2ui.status).toBe(401); + + await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, { + "x-forwarded-for": ipA, + }); + + clients.add({ + socket: {} as unknown as WebSocket, + connect: {} as never, + connId: "c1", + clientIp: ipA, + }); + + const authCanvas = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, { + headers: { "x-forwarded-for": ipA }, + }); + expect(authCanvas.status).toBe(200); + expect(await authCanvas.text()).toBe("ok"); + + const otherIpStillBlocked = await fetch( + `http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, + { + headers: { "x-forwarded-for": ipB }, + }, + ); + expect(otherIpStillBlocked.status).toBe(401); + + await new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, { + headers: { "x-forwarded-for": ipA }, + }); + 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); + }); + } finally { + await listener.close(); + canvasWss.close(); + wss.close(); + } + }, + }); + }, 60_000); +}); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 9593ca204..ad4363028 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -882,6 +882,7 @@ export function attachGatewayWsMessageHandler(params: { connect: connectParams, connId, presenceKey, + clientIp: reportedClientIp, }; setClient(nextClient); setHandshakeState("connected"); diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index daeda9a29..ae68719f7 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -6,4 +6,5 @@ export type GatewayWsClient = { connect: ConnectParams; connId: string; presenceKey?: string; + clientIp?: string; };