mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix(gateway): harden canvas auth with session capabilities
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
87
src/gateway/canvas-capability.ts
Normal file
87
src/gateway/canvas-capability.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -7,4 +7,6 @@ export type GatewayWsClient = {
|
||||
connId: string;
|
||||
presenceKey?: string;
|
||||
clientIp?: string;
|
||||
canvasCapability?: string;
|
||||
canvasCapabilityExpiresAtMs?: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user