fix(gateway): harden canvas auth with session capabilities

This commit is contained in:
Peter Steinberger
2026-02-19 15:50:42 +01:00
parent f76f98b268
commit c45f3c5b00
11 changed files with 353 additions and 126 deletions

View File

@@ -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.

View File

@@ -2169,7 +2169,8 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <token>`.
- `http://<gateway-host>:<gateway.port>/__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/`.

View File

@@ -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).

View File

@@ -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();
};

View File

@@ -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,
};
}

View File

@@ -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", {

View File

@@ -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 {

View File

@@ -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<GatewayWsClient>, clientIp: string): boolean {
function hasAuthorizedNodeWsClientForCanvasCapability(
clients: Set<GatewayWsClient>,
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<GatewayWsClient>;
canvasCapability?: string;
malformedScopedPath?: boolean;
rateLimiter?: AuthRateLimiter;
}): Promise<GatewayAuthResult> {
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) {

View File

@@ -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",

View File

@@ -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");

View File

@@ -7,4 +7,6 @@ export type GatewayWsClient = {
connId: string;
presenceKey?: string;
clientIp?: string;
canvasCapability?: string;
canvasCapabilityExpiresAtMs?: number;
};