mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(gateway): require auth for canvas host and a2ui assets (#9518) (thanks @coygeek)
This commit is contained in:
@@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
|
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
|
||||||
- Telegram: preserve DM topic threadId in deliveryContext. (#9039) Thanks @lailoo.
|
- Telegram: preserve DM topic threadId in deliveryContext. (#9039) Thanks @lailoo.
|
||||||
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
|
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
|
||||||
|
- Security: require gateway auth for Canvas host and A2UI assets. (#9518) Thanks @coygeek.
|
||||||
|
|
||||||
## 2026.2.2-3
|
## 2026.2.2-3
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ vi.mock("../infra/shell-env.js", async (importOriginal) => {
|
|||||||
return { ...mod, getShellPathFromLoginShell: () => null };
|
return { ...mod, getShellPathFromLoginShell: () => null };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock("../plugins/tools.js", () => ({
|
||||||
|
resolvePluginTools: () => [],
|
||||||
|
getPluginToolMeta: () => undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
|
vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
|
||||||
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
|
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
|
||||||
const approvals: ExecApprovalsResolved = {
|
const approvals: ExecApprovalsResolved = {
|
||||||
@@ -104,10 +109,22 @@ describe("createOpenClawCodingTools safeBins", () => {
|
|||||||
expect(execTool).toBeDefined();
|
expect(execTool).toBeDefined();
|
||||||
|
|
||||||
const marker = `safe-bins-${Date.now()}`;
|
const marker = `safe-bins-${Date.now()}`;
|
||||||
const result = await execTool!.execute("call1", {
|
const prevShellEnvTimeoutMs = process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS;
|
||||||
command: `echo ${marker}`,
|
process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000";
|
||||||
workdir: tmpDir,
|
const result = await (async () => {
|
||||||
});
|
try {
|
||||||
|
return await execTool!.execute("call1", {
|
||||||
|
command: `echo ${marker}`,
|
||||||
|
workdir: tmpDir,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (prevShellEnvTimeoutMs === undefined) {
|
||||||
|
delete process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = prevShellEnvTimeoutMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
const text = result.content.find((content) => content.type === "text")?.text ?? "";
|
const text = result.content.find((content) => content.type === "text")?.text ?? "";
|
||||||
|
|
||||||
expect(result.details.status).toBe("completed");
|
expect(result.details.status).toBe("completed");
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ vi.mock("../infra/shell-env.js", async (importOriginal) => {
|
|||||||
const mod = await importOriginal<typeof import("../infra/shell-env.js")>();
|
const mod = await importOriginal<typeof import("../infra/shell-env.js")>();
|
||||||
return { ...mod, getShellPathFromLoginShell: () => null };
|
return { ...mod, getShellPathFromLoginShell: () => null };
|
||||||
});
|
});
|
||||||
|
|
||||||
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
|
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const runtime = {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
vi.mock("./plugin-registry.js", () => ({ ensurePluginRegistryLoaded: () => undefined }));
|
||||||
|
|
||||||
vi.mock("../commands/message.js", () => ({ messageCommand }));
|
vi.mock("../commands/message.js", () => ({ messageCommand }));
|
||||||
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
vi.mock("../commands/status.js", () => ({ statusCommand }));
|
||||||
vi.mock("../commands/configure.js", () => ({
|
vi.mock("../commands/configure.js", () => ({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { createServer as createHttpsServer } from "node:https";
|
import { createServer as createHttpsServer } from "node:https";
|
||||||
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||||
import type { createSubsystemLogger } from "../logging/subsystem.js";
|
import type { createSubsystemLogger } from "../logging/subsystem.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,
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
} from "../canvas-host/a2ui.js";
|
} from "../canvas-host/a2ui.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
import { handleSlackHttpRequest } from "../slack/http/index.js";
|
||||||
import { authorizeGatewayConnect } from "./auth.js";
|
import { authorizeGatewayConnect, isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js";
|
||||||
import {
|
import {
|
||||||
handleControlUiAvatarRequest,
|
handleControlUiAvatarRequest,
|
||||||
handleControlUiHttpRequest,
|
handleControlUiHttpRequest,
|
||||||
@@ -38,7 +39,8 @@ import {
|
|||||||
resolveHookDeliver,
|
resolveHookDeliver,
|
||||||
} from "./hooks.js";
|
} from "./hooks.js";
|
||||||
import { sendUnauthorized } from "./http-common.js";
|
import { sendUnauthorized } from "./http-common.js";
|
||||||
import { getBearerToken } from "./http-utils.js";
|
import { getBearerToken, getHeader } from "./http-utils.js";
|
||||||
|
import { 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 { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
|
||||||
@@ -78,6 +80,51 @@ function isCanvasPath(pathname: string): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAuthorizedWsClientForIp(clients: Set<GatewayWsClient>, clientIp: string): boolean {
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.clientIp && client.clientIp === clientIp) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authorizeCanvasRequest(params: {
|
||||||
|
req: IncomingMessage;
|
||||||
|
auth: ResolvedGatewayAuth;
|
||||||
|
trustedProxies: string[];
|
||||||
|
clients: Set<GatewayWsClient>;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { req, auth, trustedProxies, clients } = params;
|
||||||
|
if (isLocalDirectRequest(req, trustedProxies)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getBearerToken(req);
|
||||||
|
if (token) {
|
||||||
|
const authResult = await authorizeGatewayConnect({
|
||||||
|
auth: { ...auth, allowTailscale: false },
|
||||||
|
connectAuth: { token, password: token },
|
||||||
|
req,
|
||||||
|
trustedProxies,
|
||||||
|
});
|
||||||
|
if (authResult.ok) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIp = resolveGatewayClientIp({
|
||||||
|
remoteAddr: req.socket?.remoteAddress ?? "",
|
||||||
|
forwardedFor: getHeader(req, "x-forwarded-for"),
|
||||||
|
realIp: getHeader(req, "x-real-ip"),
|
||||||
|
trustedProxies,
|
||||||
|
});
|
||||||
|
if (!clientIp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hasAuthorizedWsClientForIp(clients, clientIp);
|
||||||
|
}
|
||||||
|
|
||||||
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
||||||
|
|
||||||
export function createHooksRequestHandler(
|
export function createHooksRequestHandler(
|
||||||
@@ -226,6 +273,7 @@ export function createHooksRequestHandler(
|
|||||||
|
|
||||||
export function createGatewayHttpServer(opts: {
|
export function createGatewayHttpServer(opts: {
|
||||||
canvasHost: CanvasHostHandler | null;
|
canvasHost: CanvasHostHandler | null;
|
||||||
|
clients: Set<GatewayWsClient>;
|
||||||
controlUiEnabled: boolean;
|
controlUiEnabled: boolean;
|
||||||
controlUiBasePath: string;
|
controlUiBasePath: string;
|
||||||
controlUiRoot?: ControlUiRootState;
|
controlUiRoot?: ControlUiRootState;
|
||||||
@@ -234,11 +282,12 @@ export function createGatewayHttpServer(opts: {
|
|||||||
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
|
||||||
handleHooksRequest: HooksRequestHandler;
|
handleHooksRequest: HooksRequestHandler;
|
||||||
handlePluginRequest?: HooksRequestHandler;
|
handlePluginRequest?: HooksRequestHandler;
|
||||||
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
tlsOptions?: TlsOptions;
|
tlsOptions?: TlsOptions;
|
||||||
}): HttpServer {
|
}): HttpServer {
|
||||||
const {
|
const {
|
||||||
canvasHost,
|
canvasHost,
|
||||||
|
clients,
|
||||||
controlUiEnabled,
|
controlUiEnabled,
|
||||||
controlUiBasePath,
|
controlUiBasePath,
|
||||||
controlUiRoot,
|
controlUiRoot,
|
||||||
@@ -305,16 +354,15 @@ export function createGatewayHttpServer(opts: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (canvasHost) {
|
if (canvasHost) {
|
||||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
if (isCanvasPath(url.pathname)) {
|
if (isCanvasPath(url.pathname)) {
|
||||||
const token = getBearerToken(req);
|
const ok = await authorizeCanvasRequest({
|
||||||
const authResult = await authorizeGatewayConnect({
|
|
||||||
auth: resolvedAuth,
|
|
||||||
connectAuth: token ? { token, password: token } : null,
|
|
||||||
req,
|
req,
|
||||||
|
auth: resolvedAuth,
|
||||||
trustedProxies,
|
trustedProxies,
|
||||||
|
clients,
|
||||||
});
|
});
|
||||||
if (!authResult.ok) {
|
if (!ok) {
|
||||||
sendUnauthorized(res);
|
sendUnauthorized(res);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -363,41 +411,38 @@ export function attachGatewayUpgradeHandler(opts: {
|
|||||||
httpServer: HttpServer;
|
httpServer: HttpServer;
|
||||||
wss: WebSocketServer;
|
wss: WebSocketServer;
|
||||||
canvasHost: CanvasHostHandler | null;
|
canvasHost: CanvasHostHandler | null;
|
||||||
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
|
clients: Set<GatewayWsClient>;
|
||||||
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
}) {
|
}) {
|
||||||
const { httpServer, wss, canvasHost, resolvedAuth } = opts;
|
const { httpServer, wss, canvasHost, clients, resolvedAuth } = opts;
|
||||||
httpServer.on("upgrade", (req, socket, head) => {
|
httpServer.on("upgrade", (req, socket, head) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (canvasHost) {
|
if (canvasHost) {
|
||||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
if (url.pathname === CANVAS_WS_PATH) {
|
if (url.pathname === CANVAS_WS_PATH) {
|
||||||
const configSnapshot = loadConfig();
|
const configSnapshot = loadConfig();
|
||||||
const token = getBearerToken(req);
|
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||||
const authResult = await authorizeGatewayConnect({
|
const ok = await authorizeCanvasRequest({
|
||||||
auth: resolvedAuth,
|
|
||||||
connectAuth: token ? { token, password: token } : null,
|
|
||||||
req,
|
req,
|
||||||
trustedProxies: configSnapshot.gateway?.trustedProxies ?? [],
|
auth: resolvedAuth,
|
||||||
|
trustedProxies,
|
||||||
|
clients,
|
||||||
});
|
});
|
||||||
if (!authResult.ok) {
|
if (!ok) {
|
||||||
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (canvasHost.handleUpgrade(req, socket, head)) {
|
||||||
if (canvasHost?.handleUpgrade(req, socket, head)) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
wss.emit("connection", ws, req);
|
wss.emit("connection", ws, req);
|
||||||
});
|
});
|
||||||
})().catch(() => {
|
})().catch(() => {
|
||||||
try {
|
socket.destroy();
|
||||||
socket.destroy();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clients = new Set<GatewayWsClient>();
|
||||||
|
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
|
||||||
|
|
||||||
const handleHooksRequest = createGatewayHooksRequestHandler({
|
const handleHooksRequest = createGatewayHooksRequestHandler({
|
||||||
deps: params.deps,
|
deps: params.deps,
|
||||||
getHooksConfig: params.hooksConfig,
|
getHooksConfig: params.hooksConfig,
|
||||||
@@ -126,6 +129,7 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
for (const host of bindHosts) {
|
for (const host of bindHosts) {
|
||||||
const httpServer = createGatewayHttpServer({
|
const httpServer = createGatewayHttpServer({
|
||||||
canvasHost,
|
canvasHost,
|
||||||
|
clients,
|
||||||
controlUiEnabled: params.controlUiEnabled,
|
controlUiEnabled: params.controlUiEnabled,
|
||||||
controlUiBasePath: params.controlUiBasePath,
|
controlUiBasePath: params.controlUiBasePath,
|
||||||
controlUiRoot: params.controlUiRoot,
|
controlUiRoot: params.controlUiRoot,
|
||||||
@@ -168,12 +172,11 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
httpServer: server,
|
httpServer: server,
|
||||||
wss,
|
wss,
|
||||||
canvasHost,
|
canvasHost,
|
||||||
|
clients,
|
||||||
resolvedAuth: params.resolvedAuth,
|
resolvedAuth: params.resolvedAuth,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const clients = new Set<GatewayWsClient>();
|
|
||||||
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
|
|
||||||
const agentRunSeq = new Map<string, number>();
|
const agentRunSeq = new Map<string, number>();
|
||||||
const dedupe = new Map<string, DedupeEntry>();
|
const dedupe = new Map<string, DedupeEntry>();
|
||||||
const chatRunState = createChatRunState();
|
const chatRunState = createChatRunState();
|
||||||
|
|||||||
212
src/gateway/server.canvas-auth.e2e.test.ts
Normal file
212
src/gateway/server.canvas-auth.e2e.test.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { WebSocket, WebSocketServer } from "ws";
|
||||||
|
import type { CanvasHostHandler } from "../canvas-host/server.js";
|
||||||
|
import type { ResolvedGatewayAuth } from "./auth.js";
|
||||||
|
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||||
|
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../canvas-host/a2ui.js";
|
||||||
|
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
|
||||||
|
|
||||||
|
async function withTempConfig(params: { cfg: unknown; run: () => Promise<void> }): Promise<void> {
|
||||||
|
const prevConfigPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||||
|
const prevDisableCache = process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
|
||||||
|
|
||||||
|
const dir = await mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-auth-test-"));
|
||||||
|
const configPath = path.join(dir, "openclaw.json");
|
||||||
|
|
||||||
|
process.env.OPENCLAW_CONFIG_PATH = configPath;
|
||||||
|
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeFile(configPath, JSON.stringify(params.cfg, null, 2), "utf-8");
|
||||||
|
await params.run();
|
||||||
|
} finally {
|
||||||
|
if (prevConfigPath === undefined) {
|
||||||
|
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCLAW_CONFIG_PATH = prevConfigPath;
|
||||||
|
}
|
||||||
|
if (prevDisableCache === undefined) {
|
||||||
|
delete process.env.OPENCLAW_DISABLE_CONFIG_CACHE;
|
||||||
|
} else {
|
||||||
|
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = prevDisableCache;
|
||||||
|
}
|
||||||
|
await rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listen(server: ReturnType<typeof createGatewayHttpServer>): Promise<{
|
||||||
|
port: number;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const addr = server.address();
|
||||||
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
||||||
|
return {
|
||||||
|
port,
|
||||||
|
close: async () => {
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
server.close((err) => (err ? reject(err) : resolve())),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectWsRejected(url: string, headers: Record<string, string>): Promise<void> {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(url, { headers });
|
||||||
|
const timer = setTimeout(() => reject(new Error("timeout")), 10_000);
|
||||||
|
ws.once("open", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.terminate();
|
||||||
|
reject(new Error("expected ws to reject"));
|
||||||
|
});
|
||||||
|
ws.once("unexpected-response", (_req, res) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
expect(res.statusCode).toBe(401);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
ws.once("error", () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("gateway canvas host auth", () => {
|
||||||
|
test("authorizes canvas/a2ui HTTP and canvas WS by matching an authenticated gateway ws client ip", async () => {
|
||||||
|
const resolvedAuth: ResolvedGatewayAuth = {
|
||||||
|
mode: "token",
|
||||||
|
token: "test-token",
|
||||||
|
password: undefined,
|
||||||
|
allowTailscale: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await withTempConfig({
|
||||||
|
cfg: {
|
||||||
|
gateway: {
|
||||||
|
trustedProxies: ["127.0.0.1"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
run: async () => {
|
||||||
|
const clients = new Set<GatewayWsClient>();
|
||||||
|
|
||||||
|
const canvasWss = new WebSocketServer({ noServer: true });
|
||||||
|
const canvasHost: CanvasHostHandler = {
|
||||||
|
rootDir: "test",
|
||||||
|
close: async () => {},
|
||||||
|
handleUpgrade: (req, socket, head) => {
|
||||||
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
|
if (url.pathname !== CANVAS_WS_PATH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
canvasWss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const httpServer = createGatewayHttpServer({
|
||||||
|
canvasHost,
|
||||||
|
clients,
|
||||||
|
controlUiEnabled: false,
|
||||||
|
controlUiBasePath: "/__control__",
|
||||||
|
openAiChatCompletionsEnabled: false,
|
||||||
|
openResponsesEnabled: false,
|
||||||
|
handleHooksRequest: async () => false,
|
||||||
|
resolvedAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
attachGatewayUpgradeHandler({
|
||||||
|
httpServer,
|
||||||
|
wss,
|
||||||
|
canvasHost,
|
||||||
|
clients,
|
||||||
|
resolvedAuth,
|
||||||
|
});
|
||||||
|
|
||||||
|
const listener = await listen(httpServer);
|
||||||
|
try {
|
||||||
|
const ipA = "203.0.113.10";
|
||||||
|
const ipB = "203.0.113.11";
|
||||||
|
|
||||||
|
const unauthCanvas = await fetch(
|
||||||
|
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||||
|
{
|
||||||
|
headers: { "x-forwarded-for": ipA },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(unauthCanvas.status).toBe(401);
|
||||||
|
|
||||||
|
const unauthA2ui = await fetch(`http://127.0.0.1:${listener.port}${A2UI_PATH}/`, {
|
||||||
|
headers: { "x-forwarded-for": ipA },
|
||||||
|
});
|
||||||
|
expect(unauthA2ui.status).toBe(401);
|
||||||
|
|
||||||
|
await expectWsRejected(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
||||||
|
"x-forwarded-for": ipA,
|
||||||
|
});
|
||||||
|
|
||||||
|
clients.add({
|
||||||
|
socket: {} as unknown as WebSocket,
|
||||||
|
connect: {} as never,
|
||||||
|
connId: "c1",
|
||||||
|
clientIp: ipA,
|
||||||
|
});
|
||||||
|
|
||||||
|
const authCanvas = await fetch(`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`, {
|
||||||
|
headers: { "x-forwarded-for": ipA },
|
||||||
|
});
|
||||||
|
expect(authCanvas.status).toBe(200);
|
||||||
|
expect(await authCanvas.text()).toBe("ok");
|
||||||
|
|
||||||
|
const otherIpStillBlocked = await fetch(
|
||||||
|
`http://127.0.0.1:${listener.port}${CANVAS_HOST_PATH}/`,
|
||||||
|
{
|
||||||
|
headers: { "x-forwarded-for": ipB },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(otherIpStillBlocked.status).toBe(401);
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(`ws://127.0.0.1:${listener.port}${CANVAS_WS_PATH}`, {
|
||||||
|
headers: { "x-forwarded-for": ipA },
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await listener.close();
|
||||||
|
canvasWss.close();
|
||||||
|
wss.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -882,6 +882,7 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
connect: connectParams,
|
connect: connectParams,
|
||||||
connId,
|
connId,
|
||||||
presenceKey,
|
presenceKey,
|
||||||
|
clientIp: reportedClientIp,
|
||||||
};
|
};
|
||||||
setClient(nextClient);
|
setClient(nextClient);
|
||||||
setHandshakeState("connected");
|
setHandshakeState("connected");
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export type GatewayWsClient = {
|
|||||||
connect: ConnectParams;
|
connect: ConnectParams;
|
||||||
connId: string;
|
connId: string;
|
||||||
presenceKey?: string;
|
presenceKey?: string;
|
||||||
|
clientIp?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user