mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 06:12:45 +00: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 {
|
import {
|
||||||
createServer as createHttpServer,
|
createServer as createHttpServer,
|
||||||
type Server as HttpServer,
|
type Server as HttpServer,
|
||||||
@@ -5,8 +7,10 @@ import {
|
|||||||
type ServerResponse,
|
type ServerResponse,
|
||||||
} from "node:http";
|
} from "node:http";
|
||||||
import { createServer as createHttpsServer } from "node:https";
|
import { createServer as createHttpsServer } from "node:https";
|
||||||
import type { TlsOptions } from "node:tls";
|
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||||
import type { WebSocketServer } from "ws";
|
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 { resolveAgentAvatar } from "../agents/identity-avatar.js";
|
||||||
import {
|
import {
|
||||||
A2UI_PATH,
|
A2UI_PATH,
|
||||||
@@ -14,12 +18,9 @@ import {
|
|||||||
CANVAS_WS_PATH,
|
CANVAS_WS_PATH,
|
||||||
handleA2uiHttpRequest,
|
handleA2uiHttpRequest,
|
||||||
} from "../canvas-host/a2ui.js";
|
} from "../canvas-host/a2ui.js";
|
||||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
|
||||||
import { safeEqualSecret } from "../security/secret-equal.js";
|
import { safeEqualSecret } from "../security/secret-equal.js";
|
||||||
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
||||||
import type { AuthRateLimiter } from "./auth-rate-limit.js";
|
|
||||||
import {
|
import {
|
||||||
authorizeGatewayConnect,
|
authorizeGatewayConnect,
|
||||||
isLocalDirectRequest,
|
isLocalDirectRequest,
|
||||||
@@ -50,10 +51,14 @@ import {
|
|||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
|
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
|
||||||
import { getBearerToken, getHeader } from "./http-utils.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 { handleOpenAiHttpRequest } from "./openai-http.js";
|
||||||
import { handleOpenResponsesHttpRequest } from "./openresponses-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";
|
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
||||||
|
|
||||||
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
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) {
|
for (const client of clients) {
|
||||||
if (client.clientIp && client.clientIp === clientIp) {
|
if (client.clientIp && client.clientIp === clientIp && isNodeWsClient(client)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,6 +130,9 @@ async function authorizeCanvasRequest(params: {
|
|||||||
return { ok: true };
|
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;
|
let lastAuthFailure: GatewayAuthResult | null = null;
|
||||||
const token = getBearerToken(req);
|
const token = getBearerToken(req);
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -150,7 +165,11 @@ async function authorizeCanvasRequest(params: {
|
|||||||
if (!isPrivateOrLoopbackAddress(clientIp)) {
|
if (!isPrivateOrLoopbackAddress(clientIp)) {
|
||||||
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
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 { ok: true };
|
||||||
}
|
}
|
||||||
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
|
||||||
|
|||||||
@@ -152,5 +152,84 @@ describe("resolveGatewayRuntimeConfig", () => {
|
|||||||
}),
|
}),
|
||||||
).rejects.toThrow("refusing to bind gateway");
|
).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";
|
} from "./auth.js";
|
||||||
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
|
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
|
||||||
import { resolveHooksConfig } from "./hooks.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";
|
import { mergeGatewayTailscaleConfig } from "./startup-auth.js";
|
||||||
|
|
||||||
export type GatewayRuntimeConfig = {
|
export type GatewayRuntimeConfig = {
|
||||||
@@ -44,6 +44,27 @@ export async function resolveGatewayRuntimeConfig(params: {
|
|||||||
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
|
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
|
||||||
const customBindHost = params.cfg.gateway?.customBindHost;
|
const customBindHost = params.cfg.gateway?.customBindHost;
|
||||||
const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, 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 =
|
const controlUiEnabled =
|
||||||
params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
|
params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
|
||||||
const openAiChatCompletionsEnabled =
|
const openAiChatCompletionsEnabled =
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { WebSocket, WebSocketServer } from "ws";
|
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 type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||||
import { createAuthRateLimiter } from "./auth-rate-limit.js";
|
|
||||||
import type { ResolvedGatewayAuth } from "./auth.js";
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
|
|
||||||
import type { GatewayWsClient } from "./server/ws-types.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";
|
import { withTempConfig } from "./test-temp-config.js";
|
||||||
|
|
||||||
async function listen(server: ReturnType<typeof createGatewayHttpServer>): Promise<{
|
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: {
|
async function withCanvasGatewayHarness(params: {
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
rateLimiter?: ReturnType<typeof createAuthRateLimiter>;
|
rateLimiter?: ReturnType<typeof createAuthRateLimiter>;
|
||||||
@@ -164,12 +183,31 @@ describe("gateway canvas host auth", () => {
|
|||||||
"x-forwarded-for": privateIpA,
|
"x-forwarded-for": privateIpA,
|
||||||
});
|
});
|
||||||
|
|
||||||
clients.add({
|
clients.add(
|
||||||
socket: {} as unknown as WebSocket,
|
makeWsClient({
|
||||||
connect: {} as never,
|
connId: "c-operator",
|
||||||
connId: "c1",
|
clientIp: privateIpA,
|
||||||
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(
|
const authCanvas = await fetch(
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||||
@@ -188,12 +226,14 @@ describe("gateway canvas host auth", () => {
|
|||||||
);
|
);
|
||||||
expect(otherIpStillBlocked.status).toBe(401);
|
expect(otherIpStillBlocked.status).toBe(401);
|
||||||
|
|
||||||
clients.add({
|
clients.add(
|
||||||
socket: {} as unknown as WebSocket,
|
makeWsClient({
|
||||||
connect: {} as never,
|
connId: "c-public",
|
||||||
connId: "c-public",
|
clientIp: publicIp,
|
||||||
clientIp: publicIp,
|
role: "node",
|
||||||
});
|
mode: "node",
|
||||||
|
}),
|
||||||
|
);
|
||||||
const publicIpStillBlocked = await fetch(
|
const publicIpStillBlocked = await fetch(
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||||
{
|
{
|
||||||
@@ -205,12 +245,14 @@ describe("gateway canvas host auth", () => {
|
|||||||
"x-forwarded-for": publicIp,
|
"x-forwarded-for": publicIp,
|
||||||
});
|
});
|
||||||
|
|
||||||
clients.add({
|
clients.add(
|
||||||
socket: {} as unknown as WebSocket,
|
makeWsClient({
|
||||||
connect: {} as never,
|
connId: "c-cgnat",
|
||||||
connId: "c-cgnat",
|
clientIp: cgnatIp,
|
||||||
clientIp: cgnatIp,
|
role: "node",
|
||||||
});
|
mode: "node",
|
||||||
|
}),
|
||||||
|
);
|
||||||
const cgnatAllowed = await fetch(
|
const cgnatAllowed = await fetch(
|
||||||
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||||
{
|
{
|
||||||
@@ -241,6 +283,60 @@ describe("gateway canvas host auth", () => {
|
|||||||
});
|
});
|
||||||
}, 60_000);
|
}, 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 () => {
|
test("returns 429 for repeated failed canvas auth attempts (HTTP + WS upgrade)", async () => {
|
||||||
const resolvedAuth: ResolvedGatewayAuth = {
|
const resolvedAuth: ResolvedGatewayAuth = {
|
||||||
mode: "token",
|
mode: "token",
|
||||||
|
|||||||
Reference in New Issue
Block a user