mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
fix(security): fail closed on gateway bind fallback and tighten canvas IP fallback
This commit is contained in:
@@ -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" };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user