From c45f3c5b004c8d63dc0e282e2176f8c9355d24f1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 19 Feb 2026 15:50:42 +0100 Subject: [PATCH] fix(gateway): harden canvas auth with session capabilities --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 3 +- docs/gateway/network-model.md | 2 +- src/canvas-host/a2ui.ts | 4 +- src/gateway/canvas-capability.ts | 87 +++++++ src/gateway/net.test.ts | 38 +++ src/gateway/net.ts | 5 +- src/gateway/server-http.ts | 81 +++--- src/gateway/server.canvas-auth.e2e.test.ts | 238 +++++++++++------- .../server/ws-connection/message-handler.ts | 18 +- src/gateway/server/ws-types.ts | 2 + 11 files changed, 353 insertions(+), 126 deletions(-) create mode 100644 src/gateway/canvas-capability.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d5b60cc28ba..2ebf3978112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Voice Call: harden `voice-call` telephony TTS override merging by blocking unsafe deep-merge keys (`__proto__`, `prototype`, `constructor`) and add regression coverage for top-level and nested prototype-pollution payloads. +- Security/Gateway/Canvas: replace shared-IP fallback auth with node-scoped session capability URLs for `/__openclaw__/canvas/*` and `/__openclaw__/a2ui/*`, fail closed when trusted-proxy requests omit forwarded client headers, and add IPv6/proxy-header regression coverage. This ships in the next npm release. Thanks @aether-ai-agent for reporting. - Security/Net: enforce strict dotted-decimal IPv4 literals in SSRF checks and fail closed on unsupported legacy forms (octal/hex/short/packed, for example `0177.0.0.1`, `127.1`, `2130706433`) before DNS lookup. - Security/Discord: enforce trusted-sender guild permission checks for moderation actions (`timeout`, `kick`, `ban`) and ignore untrusted `senderUserId` params to prevent privilege escalation in tool-driven flows. Thanks @aether-ai-agent for reporting. - Security/ACP+Exec: add `openclaw acp --token-file/--password-file` secret-file support (with inline secret flag warnings), redact ACP working-directory prefixes to `~` home-relative paths, constrain exec script preflight file inspection to the effective `workdir` boundary, and add security-audit warnings when `tools.exec.host="sandbox"` is configured while sandbox mode is off. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 8f31cea128c..54de076ba9e 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -2169,7 +2169,8 @@ Auth: `Authorization: Bearer ` or `x-openclaw-token: `. - `http://:/__openclaw__/a2ui/` - Local-only: keep `gateway.bind: "loopback"` (default). - Non-loopback binds: canvas routes require Gateway auth (token/password/trusted-proxy), same as other Gateway HTTP surfaces. -- Node WebViews typically don't send auth headers; after a node is paired and connected, the Gateway allows a private-IP fallback so the node can load canvas/A2UI without leaking secrets into URLs. +- Node WebViews typically don't send auth headers; after a node is paired and connected, the Gateway advertises node-scoped capability URLs for canvas/A2UI access. +- Capability URLs are bound to the active node WS session and expire quickly. IP-based fallback is not used. - Injects live-reload client into served HTML. - Auto-creates starter `index.html` when empty. - Also serves A2UI at `/__openclaw__/a2ui/`. diff --git a/docs/gateway/network-model.md b/docs/gateway/network-model.md index c7f65aa22dd..b57ff91f143 100644 --- a/docs/gateway/network-model.md +++ b/docs/gateway/network-model.md @@ -16,5 +16,5 @@ process that owns channel connections and the WebSocket control plane. - Canvas host is served by the Gateway HTTP server on the **same port** as the Gateway (default `18789`): - `/__openclaw__/canvas/` - `/__openclaw__/a2ui/` - When `gateway.auth` is configured and the Gateway binds beyond loopback, these routes are protected by Gateway auth (loopback requests are exempt). See [Gateway configuration](/gateway/configuration) (`canvasHost`, `gateway`). + When `gateway.auth` is configured and the Gateway binds beyond loopback, these routes are protected by Gateway auth. Node clients use node-scoped capability URLs tied to their active WS session. See [Gateway configuration](/gateway/configuration) (`canvasHost`, `gateway`). - Remote use is typically SSH tunnel or tailnet VPN. See [Remote access](/gateway/remote) and [Discovery](/gateway/discovery). diff --git a/src/canvas-host/a2ui.ts b/src/canvas-host/a2ui.ts index bac09a44383..d8cad28d197 100644 --- a/src/canvas-host/a2ui.ts +++ b/src/canvas-host/a2ui.ts @@ -120,8 +120,10 @@ export function injectCanvasLiveReload(html: string): string { globalThis.openclawSendUserAction = sendUserAction; try { + const cap = new URLSearchParams(location.search).get("oc_cap"); const proto = location.protocol === "https:" ? "wss" : "ws"; - const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(CANVAS_WS_PATH)}); + const capQuery = cap ? "?oc_cap=" + encodeURIComponent(cap) : ""; + const ws = new WebSocket(proto + "://" + location.host + ${JSON.stringify(CANVAS_WS_PATH)} + capQuery); ws.onmessage = (ev) => { if (String(ev.data || "") === "reload") location.reload(); }; diff --git a/src/gateway/canvas-capability.ts b/src/gateway/canvas-capability.ts new file mode 100644 index 00000000000..32c7bff83bf --- /dev/null +++ b/src/gateway/canvas-capability.ts @@ -0,0 +1,87 @@ +import { randomBytes } from "node:crypto"; + +export const CANVAS_CAPABILITY_PATH_PREFIX = "/__openclaw__/cap"; +export const CANVAS_CAPABILITY_QUERY_PARAM = "oc_cap"; +export const CANVAS_CAPABILITY_TTL_MS = 10 * 60_000; + +export type NormalizedCanvasScopedUrl = { + pathname: string; + capability?: string; + rewrittenUrl?: string; + scopedPath: boolean; + malformedScopedPath: boolean; +}; + +function normalizeCapability(raw: string | null | undefined): string | undefined { + const trimmed = raw?.trim(); + return trimmed ? trimmed : undefined; +} + +export function mintCanvasCapabilityToken(): string { + return randomBytes(18).toString("base64url"); +} + +export function buildCanvasScopedHostUrl(baseUrl: string, capability: string): string | undefined { + const normalizedCapability = normalizeCapability(capability); + if (!normalizedCapability) { + return undefined; + } + try { + const url = new URL(baseUrl); + const trimmedPath = url.pathname.replace(/\/+$/, ""); + const prefix = `${CANVAS_CAPABILITY_PATH_PREFIX}/${encodeURIComponent(normalizedCapability)}`; + url.pathname = `${trimmedPath}${prefix}`; + url.search = ""; + url.hash = ""; + return url.toString().replace(/\/$/, ""); + } catch { + return undefined; + } +} + +export function normalizeCanvasScopedUrl(rawUrl: string): NormalizedCanvasScopedUrl { + const url = new URL(rawUrl, "http://localhost"); + const prefix = `${CANVAS_CAPABILITY_PATH_PREFIX}/`; + let scopedPath = false; + let malformedScopedPath = false; + let capabilityFromPath: string | undefined; + let rewrittenUrl: string | undefined; + + if (url.pathname.startsWith(prefix)) { + scopedPath = true; + const remainder = url.pathname.slice(prefix.length); + const slashIndex = remainder.indexOf("/"); + if (slashIndex <= 0) { + malformedScopedPath = true; + } else { + const encodedCapability = remainder.slice(0, slashIndex); + const canonicalPath = remainder.slice(slashIndex) || "/"; + let decoded: string | undefined; + try { + decoded = decodeURIComponent(encodedCapability); + } catch { + malformedScopedPath = true; + } + capabilityFromPath = normalizeCapability(decoded); + if (!capabilityFromPath || !canonicalPath.startsWith("/")) { + malformedScopedPath = true; + } else { + url.pathname = canonicalPath; + if (!url.searchParams.has(CANVAS_CAPABILITY_QUERY_PARAM)) { + url.searchParams.set(CANVAS_CAPABILITY_QUERY_PARAM, capabilityFromPath); + } + rewrittenUrl = `${url.pathname}${url.search}`; + } + } + } + + const capability = + capabilityFromPath ?? normalizeCapability(url.searchParams.get(CANVAS_CAPABILITY_QUERY_PARAM)); + return { + pathname: url.pathname, + capability, + rewrittenUrl, + scopedPath, + malformedScopedPath, + }; +} diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 18d5fc14eff..8b37b44135d 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -5,6 +5,7 @@ import { isSecureWebSocketUrl, isTrustedProxyAddress, pickPrimaryLanIPv4, + resolveGatewayClientIp, resolveGatewayListenHosts, resolveHostName, } from "./net.js"; @@ -131,6 +132,43 @@ describe("isTrustedProxyAddress", () => { }); }); +describe("resolveGatewayClientIp", () => { + it("returns remote IP when the remote is not a trusted proxy", () => { + const ip = resolveGatewayClientIp({ + remoteAddr: "203.0.113.10", + forwardedFor: "10.0.0.2", + trustedProxies: ["127.0.0.1"], + }); + expect(ip).toBe("203.0.113.10"); + }); + + it("returns forwarded client IP when the remote is a trusted proxy", () => { + const ip = resolveGatewayClientIp({ + remoteAddr: "127.0.0.1", + forwardedFor: "10.0.0.2, 127.0.0.1", + trustedProxies: ["127.0.0.1"], + }); + expect(ip).toBe("10.0.0.2"); + }); + + it("fails closed when trusted proxy headers are missing", () => { + const ip = resolveGatewayClientIp({ + remoteAddr: "127.0.0.1", + trustedProxies: ["127.0.0.1"], + }); + expect(ip).toBeUndefined(); + }); + + it("supports IPv6 client IP forwarded by a trusted proxy", () => { + const ip = resolveGatewayClientIp({ + remoteAddr: "127.0.0.1", + realIp: "[2001:db8::5]", + trustedProxies: ["127.0.0.1"], + }); + expect(ip).toBe("2001:db8::5"); + }); +}); + describe("resolveGatewayListenHosts", () => { it("returns the input host when not loopback", async () => { const hosts = await resolveGatewayListenHosts("0.0.0.0", { diff --git a/src/gateway/net.ts b/src/gateway/net.ts index edeeb835477..28ad98027e3 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -240,7 +240,10 @@ export function resolveGatewayClientIp(params: { if (!isTrustedProxyAddress(remote, params.trustedProxies)) { return remote; } - return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote; + // Fail closed when traffic comes from a trusted proxy but client-origin headers + // are missing or invalid. Falling back to the proxy's own IP can accidentally + // treat unrelated requests as local/trusted. + return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp); } export function isLocalGatewayAddress(ip: string | undefined): boolean { diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index c4cc5754342..63185280c74 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -26,6 +26,7 @@ import { type GatewayAuthResult, type ResolvedGatewayAuth, } from "./auth.js"; +import { CANVAS_CAPABILITY_TTL_MS, normalizeCanvasScopedUrl } from "./canvas-capability.js"; import { handleControlUiAvatarRequest, handleControlUiHttpRequest, @@ -49,12 +50,7 @@ import { resolveHookDeliver, } from "./hooks.js"; import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js"; -import { getBearerToken, getHeader } from "./http-utils.js"; -import { - isPrivateOrLoopbackAddress, - isTrustedProxyAddress, - resolveGatewayClientIp, -} from "./net.js"; +import { getBearerToken } from "./http-utils.js"; import { handleOpenAiHttpRequest } from "./openai-http.js"; import { handleOpenResponsesHttpRequest } from "./openresponses-http.js"; import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "./protocol/client-info.js"; @@ -109,9 +105,24 @@ function isNodeWsClient(client: GatewayWsClient): boolean { return normalizeGatewayClientMode(client.connect.client.mode) === GATEWAY_CLIENT_MODES.NODE; } -function hasAuthorizedNodeWsClientForIp(clients: Set, clientIp: string): boolean { +function hasAuthorizedNodeWsClientForCanvasCapability( + clients: Set, + capability: string, +): boolean { + const nowMs = Date.now(); for (const client of clients) { - if (client.clientIp && client.clientIp === clientIp && isNodeWsClient(client)) { + if (!isNodeWsClient(client)) { + continue; + } + if (!client.canvasCapability || !client.canvasCapabilityExpiresAtMs) { + continue; + } + if (client.canvasCapabilityExpiresAtMs <= nowMs) { + continue; + } + if (safeEqualSecret(client.canvasCapability, capability)) { + // Sliding expiration while the connected node keeps using canvas. + client.canvasCapabilityExpiresAtMs = nowMs + CANVAS_CAPABILITY_TTL_MS; return true; } } @@ -123,16 +134,19 @@ async function authorizeCanvasRequest(params: { auth: ResolvedGatewayAuth; trustedProxies: string[]; clients: Set; + canvasCapability?: string; + malformedScopedPath?: boolean; rateLimiter?: AuthRateLimiter; }): Promise { - const { req, auth, trustedProxies, clients, rateLimiter } = params; + const { req, auth, trustedProxies, clients, canvasCapability, malformedScopedPath, rateLimiter } = + params; + if (malformedScopedPath) { + return { ok: false, reason: "unauthorized" }; + } if (isLocalDirectRequest(req, trustedProxies)) { return { ok: true }; } - const hasProxyHeaders = Boolean(getHeader(req, "x-forwarded-for") || getHeader(req, "x-real-ip")); - const remoteIsTrustedProxy = isTrustedProxyAddress(req.socket?.remoteAddress, trustedProxies); - let lastAuthFailure: GatewayAuthResult | null = null; const token = getBearerToken(req); if (token) { @@ -149,27 +163,7 @@ async function authorizeCanvasRequest(params: { lastAuthFailure = authResult; } - const clientIp = resolveGatewayClientIp({ - remoteAddr: req.socket?.remoteAddress ?? "", - forwardedFor: getHeader(req, "x-forwarded-for"), - realIp: getHeader(req, "x-real-ip"), - trustedProxies, - }); - 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" }; - } - // Ignore IP fallback when proxy headers come from an untrusted source. - if (hasProxyHeaders && !remoteIsTrustedProxy) { - return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; - } - if (hasAuthorizedNodeWsClientForIp(clients, clientIp)) { + if (canvasCapability && hasAuthorizedNodeWsClientForCanvasCapability(clients, canvasCapability)) { return { ok: true }; } return lastAuthFailure ?? { ok: false, reason: "unauthorized" }; @@ -503,6 +497,14 @@ export function createGatewayHttpServer(opts: { try { const configSnapshot = loadConfig(); const trustedProxies = configSnapshot.gateway?.trustedProxies ?? []; + const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/"); + if (scopedCanvas.malformedScopedPath) { + sendGatewayAuthFailure(res, { ok: false, reason: "unauthorized" }); + return; + } + if (scopedCanvas.rewrittenUrl) { + req.url = scopedCanvas.rewrittenUrl; + } const requestPath = new URL(req.url ?? "/", "http://localhost").pathname; if (await handleHooksRequest(req, res)) { return; @@ -571,6 +573,8 @@ export function createGatewayHttpServer(opts: { auth: resolvedAuth, trustedProxies, clients, + canvasCapability: scopedCanvas.capability, + malformedScopedPath: scopedCanvas.malformedScopedPath, rateLimiter, }); if (!ok.ok) { @@ -630,6 +634,15 @@ export function attachGatewayUpgradeHandler(opts: { const { httpServer, wss, canvasHost, clients, resolvedAuth, rateLimiter } = opts; httpServer.on("upgrade", (req, socket, head) => { void (async () => { + const scopedCanvas = normalizeCanvasScopedUrl(req.url ?? "/"); + if (scopedCanvas.malformedScopedPath) { + writeUpgradeAuthFailure(socket, { ok: false, reason: "unauthorized" }); + socket.destroy(); + return; + } + if (scopedCanvas.rewrittenUrl) { + req.url = scopedCanvas.rewrittenUrl; + } if (canvasHost) { const url = new URL(req.url ?? "/", "http://localhost"); if (url.pathname === CANVAS_WS_PATH) { @@ -640,6 +653,8 @@ export function attachGatewayUpgradeHandler(opts: { auth: resolvedAuth, trustedProxies, clients, + canvasCapability: scopedCanvas.capability, + malformedScopedPath: scopedCanvas.malformedScopedPath, rateLimiter, }); if (!ok.ok) { diff --git a/src/gateway/server.canvas-auth.e2e.test.ts b/src/gateway/server.canvas-auth.e2e.test.ts index 932fceb239d..40351595733 100644 --- a/src/gateway/server.canvas-auth.e2e.test.ts +++ b/src/gateway/server.canvas-auth.e2e.test.ts @@ -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): Promise<{ +async function listen( + server: ReturnType, + host = "127.0.0.1", +): Promise<{ + host: string; port: number; close: () => Promise; }> { - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + await new Promise((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((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; 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((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((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", diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index bd90835b7e5..a3d5f9c29c6 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -30,6 +30,11 @@ import { } from "../../auth-rate-limit.js"; import type { GatewayAuthResult, ResolvedGatewayAuth } from "../../auth.js"; import { authorizeGatewayConnect, isLocalDirectRequest } from "../../auth.js"; +import { + buildCanvasScopedHostUrl, + CANVAS_CAPABILITY_TTL_MS, + mintCanvasCapabilityToken, +} from "../../canvas-capability.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; import { isLoopbackAddress, isTrustedProxyAddress, resolveGatewayClientIp } from "../../net.js"; import { resolveHostName } from "../../net.js"; @@ -822,6 +827,15 @@ export function attachGatewayWsMessageHandler(params: { snapshot.health = cachedHealth; snapshot.stateVersion.health = getHealthVersion(); } + const canvasCapability = + role === "node" && canvasHostUrl ? mintCanvasCapabilityToken() : undefined; + const canvasCapabilityExpiresAtMs = canvasCapability + ? Date.now() + CANVAS_CAPABILITY_TTL_MS + : undefined; + const scopedCanvasHostUrl = + canvasHostUrl && canvasCapability + ? (buildCanvasScopedHostUrl(canvasHostUrl, canvasCapability) ?? canvasHostUrl) + : canvasHostUrl; const helloOk = { type: "hello-ok", protocol: PROTOCOL_VERSION, @@ -833,7 +847,7 @@ export function attachGatewayWsMessageHandler(params: { }, features: { methods: gatewayMethods, events }, snapshot, - canvasHostUrl, + canvasHostUrl: scopedCanvasHostUrl, auth: deviceToken ? { deviceToken: deviceToken.token, @@ -856,6 +870,8 @@ export function attachGatewayWsMessageHandler(params: { connId, presenceKey, clientIp: reportedClientIp, + canvasCapability, + canvasCapabilityExpiresAtMs, }; setClient(nextClient); setHandshakeState("connected"); diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index ae68719f789..f89d3fb17fa 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -7,4 +7,6 @@ export type GatewayWsClient = { connId: string; presenceKey?: string; clientIp?: string; + canvasCapability?: string; + canvasCapabilityExpiresAtMs?: number; };