fix(security): fail closed on gateway bind fallback and tighten canvas IP fallback

This commit is contained in:
Peter Steinberger
2026-02-19 14:36:39 +01:00
parent a40c10d3e2
commit 08a7967936
4 changed files with 247 additions and 32 deletions

View File

@@ -1,3 +1,5 @@
import type { TlsOptions } from "node:tls";
import type { WebSocketServer } from "ws";
import {
createServer as createHttpServer,
type Server as HttpServer,
@@ -5,8 +7,10 @@ import {
type ServerResponse,
} from "node:http";
import { createServer as createHttpsServer } from "node:https";
import type { TlsOptions } from "node:tls";
import type { WebSocketServer } from "ws";
import type { CanvasHostHandler } from "../canvas-host/server.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
import {
A2UI_PATH,
@@ -14,12 +18,9 @@ import {
CANVAS_WS_PATH,
handleA2uiHttpRequest,
} from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js";
import { loadConfig } from "../config/config.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import {
authorizeGatewayConnect,
isLocalDirectRequest,
@@ -50,10 +51,14 @@ import {
} from "./hooks.js";
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
import { getBearerToken, getHeader } from "./http-utils.js";
import { isPrivateOrLoopbackAddress, resolveGatewayClientIp } from "./net.js";
import {
isPrivateOrLoopbackAddress,
isTrustedProxyAddress,
resolveGatewayClientIp,
} from "./net.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "./protocol/client-info.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
@@ -97,9 +102,16 @@ function isCanvasPath(pathname: string): boolean {
);
}
function hasAuthorizedWsClientForIp(clients: Set<GatewayWsClient>, clientIp: string): boolean {
function isNodeWsClient(client: GatewayWsClient): boolean {
if (client.connect.role === "node") {
return true;
}
return normalizeGatewayClientMode(client.connect.client.mode) === GATEWAY_CLIENT_MODES.NODE;
}
function hasAuthorizedNodeWsClientForIp(clients: Set<GatewayWsClient>, clientIp: string): boolean {
for (const client of clients) {
if (client.clientIp && client.clientIp === clientIp) {
if (client.clientIp && client.clientIp === clientIp && isNodeWsClient(client)) {
return true;
}
}
@@ -118,6 +130,9 @@ async function authorizeCanvasRequest(params: {
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) {
@@ -150,7 +165,11 @@ async function authorizeCanvasRequest(params: {
if (!isPrivateOrLoopbackAddress(clientIp)) {
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
}
if (hasAuthorizedWsClientForIp(clients, clientIp)) {
// Ignore IP fallback when proxy headers come from an untrusted source.
if (hasProxyHeaders && !remoteIsTrustedProxy) {
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
}
if (hasAuthorizedNodeWsClientForIp(clients, clientIp)) {
return { ok: true };
}
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };

View File

@@ -152,5 +152,84 @@ describe("resolveGatewayRuntimeConfig", () => {
}),
).rejects.toThrow("refusing to bind gateway");
});
it("should reject loopback mode if host resolves to non-loopback", async () => {
const cfg = {
gateway: {
bind: "loopback" as const,
auth: {
mode: "none" as const,
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
host: "0.0.0.0",
}),
).rejects.toThrow("gateway bind=loopback resolved to non-loopback host");
});
it("should reject custom bind without customBindHost", async () => {
const cfg = {
gateway: {
bind: "custom" as const,
auth: {
mode: "token" as const,
token: "test-token-123",
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
}),
).rejects.toThrow("gateway.bind=custom requires gateway.customBindHost");
});
it("should reject custom bind with invalid customBindHost", async () => {
const cfg = {
gateway: {
bind: "custom" as const,
customBindHost: "192.168.001.100",
auth: {
mode: "token" as const,
token: "test-token-123",
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
}),
).rejects.toThrow("gateway.bind=custom requires a valid IPv4 customBindHost");
});
it("should reject custom bind if resolved host differs from configured host", async () => {
const cfg = {
gateway: {
bind: "custom" as const,
customBindHost: "192.168.1.100",
auth: {
mode: "token" as const,
token: "test-token-123",
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
host: "0.0.0.0",
}),
).rejects.toThrow("gateway bind=custom requested 192.168.1.100 but resolved 0.0.0.0");
});
});
});

View File

@@ -11,7 +11,7 @@ import {
} from "./auth.js";
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
import { resolveHooksConfig } from "./hooks.js";
import { isLoopbackHost, resolveGatewayBindHost } from "./net.js";
import { isLoopbackHost, isValidIPv4, resolveGatewayBindHost } from "./net.js";
import { mergeGatewayTailscaleConfig } from "./startup-auth.js";
export type GatewayRuntimeConfig = {
@@ -44,6 +44,27 @@ export async function resolveGatewayRuntimeConfig(params: {
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
const customBindHost = params.cfg.gateway?.customBindHost;
const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
if (bindMode === "loopback" && !isLoopbackHost(bindHost)) {
throw new Error(
`gateway bind=loopback resolved to non-loopback host ${bindHost}; refusing fallback to a network bind`,
);
}
if (bindMode === "custom") {
const configuredCustomBindHost = customBindHost?.trim();
if (!configuredCustomBindHost) {
throw new Error("gateway.bind=custom requires gateway.customBindHost");
}
if (!isValidIPv4(configuredCustomBindHost)) {
throw new Error(
`gateway.bind=custom requires a valid IPv4 customBindHost (got ${configuredCustomBindHost})`,
);
}
if (bindHost !== configuredCustomBindHost) {
throw new Error(
`gateway bind=custom requested ${configuredCustomBindHost} but resolved ${bindHost}; refusing fallback`,
);
}
}
const controlUiEnabled =
params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
const openAiChatCompletionsEnabled =

View File

@@ -1,11 +1,11 @@
import { describe, expect, test } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui.js";
import type { CanvasHostHandler } from "../canvas-host/server.js";
import { createAuthRateLimiter } from "./auth-rate-limit.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui.js";
import { createAuthRateLimiter } from "./auth-rate-limit.js";
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
import { withTempConfig } from "./test-temp-config.js";
async function listen(server: ReturnType<typeof createGatewayHttpServer>): Promise<{
@@ -50,6 +50,25 @@ async function expectWsRejected(
});
}
function makeWsClient(params: {
connId: string;
clientIp: string;
role: "node" | "operator";
mode: "node" | "backend";
}): GatewayWsClient {
return {
socket: {} as unknown as WebSocket,
connect: {
role: params.role,
client: {
mode: params.mode,
},
} as GatewayWsClient["connect"],
connId: params.connId,
clientIp: params.clientIp,
};
}
async function withCanvasGatewayHarness(params: {
resolvedAuth: ResolvedGatewayAuth;
rateLimiter?: ReturnType<typeof createAuthRateLimiter>;
@@ -164,12 +183,31 @@ describe("gateway canvas host auth", () => {
"x-forwarded-for": privateIpA,
});
clients.add({
socket: {} as unknown as WebSocket,
connect: {} as never,
connId: "c1",
clientIp: privateIpA,
});
clients.add(
makeWsClient({
connId: "c-operator",
clientIp: privateIpA,
role: "operator",
mode: "backend",
}),
);
const operatorCanvasStillBlocked = await fetch(
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
{
headers: { "x-forwarded-for": privateIpA },
},
);
expect(operatorCanvasStillBlocked.status).toBe(401);
clients.add(
makeWsClient({
connId: "c-node",
clientIp: privateIpA,
role: "node",
mode: "node",
}),
);
const authCanvas = await fetch(
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
@@ -188,12 +226,14 @@ describe("gateway canvas host auth", () => {
);
expect(otherIpStillBlocked.status).toBe(401);
clients.add({
socket: {} as unknown as WebSocket,
connect: {} as never,
connId: "c-public",
clientIp: publicIp,
});
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}/`,
{
@@ -205,12 +245,14 @@ describe("gateway canvas host auth", () => {
"x-forwarded-for": publicIp,
});
clients.add({
socket: {} as unknown as WebSocket,
connect: {} as never,
connId: "c-cgnat",
clientIp: cgnatIp,
});
clients.add(
makeWsClient({
connId: "c-cgnat",
clientIp: cgnatIp,
role: "node",
mode: "node",
}),
);
const cgnatAllowed = await fetch(
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
{
@@ -241,6 +283,60 @@ describe("gateway canvas host auth", () => {
});
}, 60_000);
test("denies canvas IP fallback when proxy headers come from untrusted source", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: {
gateway: {
trustedProxies: [],
},
},
run: async () => {
await withCanvasGatewayHarness({
resolvedAuth,
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 }) => {
clients.add(
makeWsClient({
connId: "c-loopback-node",
clientIp: "127.0.0.1",
role: "node",
mode: "node",
}),
);
const res = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
headers: { "x-forwarded-for": "192.168.1.10" },
});
expect(res.status).toBe(401);
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
"x-forwarded-for": "192.168.1.10",
});
},
});
},
});
}, 60_000);
test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",