Skip to content

Commit a459e23

Browse files
committed
fix(gateway): require auth for canvas host and a2ui assets (#9518) (thanks @coygeek)
1 parent 47538bc commit a459e23

File tree

9 files changed

+314
-33
lines changed

9 files changed

+314
-33
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai
6666
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
6767
- Telegram: preserve DM topic threadId in deliveryContext. (#9039) Thanks @lailoo.
6868
- macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.
69+
- Security: require gateway auth for Canvas host and A2UI assets. (#9518) Thanks @coygeek.
6970

7071
## 2026.2.2-3
7172

src/agents/pi-tools.safe-bins.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ vi.mock("../infra/shell-env.js", async (importOriginal) => {
4141
return { ...mod, getShellPathFromLoginShell: () => null };
4242
});
4343

44+
vi.mock("../plugins/tools.js", () => ({
45+
resolvePluginTools: () => [],
46+
getPluginToolMeta: () => undefined,
47+
}));
48+
4449
vi.mock("../infra/exec-approvals.js", async (importOriginal) => {
4550
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
4651
const approvals: ExecApprovalsResolved = {
@@ -104,10 +109,22 @@ describe("createOpenClawCodingTools safeBins", () => {
104109
expect(execTool).toBeDefined();
105110

106111
const marker = `safe-bins-${Date.now()}`;
107-
const result = await execTool!.execute("call1", {
108-
command: `echo ${marker}`,
109-
workdir: tmpDir,
110-
});
112+
const prevShellEnvTimeoutMs = process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS;
113+
process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = "1000";
114+
const result = await (async () => {
115+
try {
116+
return await execTool!.execute("call1", {
117+
command: `echo ${marker}`,
118+
workdir: tmpDir,
119+
});
120+
} finally {
121+
if (prevShellEnvTimeoutMs === undefined) {
122+
delete process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS;
123+
} else {
124+
process.env.OPENCLAW_SHELL_ENV_TIMEOUT_MS = prevShellEnvTimeoutMs;
125+
}
126+
}
127+
})();
111128
const text = result.content.find((content) => content.type === "text")?.text ?? "";
112129

113130
expect(result.details.status).toBe("completed");

src/agents/pi-tools.workspace-paths.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ vi.mock("../infra/shell-env.js", async (importOriginal) => {
1313
const mod = await importOriginal<typeof import("../infra/shell-env.js")>();
1414
return { ...mod, getShellPathFromLoginShell: () => null };
1515
});
16-
1716
async function withTempDir<T>(prefix: string, fn: (dir: string) => Promise<T>) {
1817
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
1918
try {

src/cli/program.smoke.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ const runtime = {
2222
}),
2323
};
2424

25+
vi.mock("./plugin-registry.js", () => ({ ensurePluginRegistryLoaded: () => undefined }));
26+
2527
vi.mock("../commands/message.js", () => ({ messageCommand }));
2628
vi.mock("../commands/status.js", () => ({ statusCommand }));
2729
vi.mock("../commands/configure.js", () => ({

src/gateway/server-http.ts

Lines changed: 71 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { createServer as createHttpsServer } from "node:https";
1010
import type { CanvasHostHandler } from "../canvas-host/server.js";
1111
import type { createSubsystemLogger } from "../logging/subsystem.js";
12+
import type { GatewayWsClient } from "./server/ws-types.js";
1213
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
1314
import {
1415
A2UI_PATH,
@@ -18,7 +19,7 @@ import {
1819
} from "../canvas-host/a2ui.js";
1920
import { loadConfig } from "../config/config.js";
2021
import { handleSlackHttpRequest } from "../slack/http/index.js";
21-
import { authorizeGatewayConnect } from "./auth.js";
22+
import { authorizeGatewayConnect, isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js";
2223
import {
2324
handleControlUiAvatarRequest,
2425
handleControlUiHttpRequest,
@@ -38,7 +39,8 @@ import {
3839
resolveHookDeliver,
3940
} from "./hooks.js";
4041
import { sendUnauthorized } from "./http-common.js";
41-
import { getBearerToken } from "./http-utils.js";
42+
import { getBearerToken, getHeader } from "./http-utils.js";
43+
import { resolveGatewayClientIp } from "./net.js";
4244
import { handleOpenAiHttpRequest } from "./openai-http.js";
4345
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
4446
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
@@ -78,6 +80,51 @@ function isCanvasPath(pathname: string): boolean {
7880
);
7981
}
8082

83+
function hasAuthorizedWsClientForIp(clients: Set<GatewayWsClient>, clientIp: string): boolean {
84+
for (const client of clients) {
85+
if (client.clientIp && client.clientIp === clientIp) {
86+
return true;
87+
}
88+
}
89+
return false;
90+
}
91+
92+
async function authorizeCanvasRequest(params: {
93+
req: IncomingMessage;
94+
auth: ResolvedGatewayAuth;
95+
trustedProxies: string[];
96+
clients: Set<GatewayWsClient>;
97+
}): Promise<boolean> {
98+
const { req, auth, trustedProxies, clients } = params;
99+
if (isLocalDirectRequest(req, trustedProxies)) {
100+
return true;
101+
}
102+
103+
const token = getBearerToken(req);
104+
if (token) {
105+
const authResult = await authorizeGatewayConnect({
106+
auth: { ...auth, allowTailscale: false },
107+
connectAuth: { token, password: token },
108+
req,
109+
trustedProxies,
110+
});
111+
if (authResult.ok) {
112+
return true;
113+
}
114+
}
115+
116+
const clientIp = resolveGatewayClientIp({
117+
remoteAddr: req.socket?.remoteAddress ?? "",
118+
forwardedFor: getHeader(req, "x-forwarded-for"),
119+
realIp: getHeader(req, "x-real-ip"),
120+
trustedProxies,
121+
});
122+
if (!clientIp) {
123+
return false;
124+
}
125+
return hasAuthorizedWsClientForIp(clients, clientIp);
126+
}
127+
81128
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
82129

83130
export function createHooksRequestHandler(
@@ -226,6 +273,7 @@ export function createHooksRequestHandler(
226273

227274
export function createGatewayHttpServer(opts: {
228275
canvasHost: CanvasHostHandler | null;
276+
clients: Set<GatewayWsClient>;
229277
controlUiEnabled: boolean;
230278
controlUiBasePath: string;
231279
controlUiRoot?: ControlUiRootState;
@@ -234,11 +282,12 @@ export function createGatewayHttpServer(opts: {
234282
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
235283
handleHooksRequest: HooksRequestHandler;
236284
handlePluginRequest?: HooksRequestHandler;
237-
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
285+
resolvedAuth: ResolvedGatewayAuth;
238286
tlsOptions?: TlsOptions;
239287
}): HttpServer {
240288
const {
241289
canvasHost,
290+
clients,
242291
controlUiEnabled,
243292
controlUiBasePath,
244293
controlUiRoot,
@@ -305,16 +354,15 @@ export function createGatewayHttpServer(opts: {
305354
}
306355
}
307356
if (canvasHost) {
308-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
357+
const url = new URL(req.url ?? "/", "http://localhost");
309358
if (isCanvasPath(url.pathname)) {
310-
const token = getBearerToken(req);
311-
const authResult = await authorizeGatewayConnect({
312-
auth: resolvedAuth,
313-
connectAuth: token ? { token, password: token } : null,
359+
const ok = await authorizeCanvasRequest({
314360
req,
361+
auth: resolvedAuth,
315362
trustedProxies,
363+
clients,
316364
});
317-
if (!authResult.ok) {
365+
if (!ok) {
318366
sendUnauthorized(res);
319367
return;
320368
}
@@ -363,41 +411,38 @@ export function attachGatewayUpgradeHandler(opts: {
363411
httpServer: HttpServer;
364412
wss: WebSocketServer;
365413
canvasHost: CanvasHostHandler | null;
366-
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
414+
clients: Set<GatewayWsClient>;
415+
resolvedAuth: ResolvedGatewayAuth;
367416
}) {
368-
const { httpServer, wss, canvasHost, resolvedAuth } = opts;
417+
const { httpServer, wss, canvasHost, clients, resolvedAuth } = opts;
369418
httpServer.on("upgrade", (req, socket, head) => {
370419
void (async () => {
371420
if (canvasHost) {
372-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
421+
const url = new URL(req.url ?? "/", "http://localhost");
373422
if (url.pathname === CANVAS_WS_PATH) {
374423
const configSnapshot = loadConfig();
375-
const token = getBearerToken(req);
376-
const authResult = await authorizeGatewayConnect({
377-
auth: resolvedAuth,
378-
connectAuth: token ? { token, password: token } : null,
424+
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
425+
const ok = await authorizeCanvasRequest({
379426
req,
380-
trustedProxies: configSnapshot.gateway?.trustedProxies ?? [],
427+
auth: resolvedAuth,
428+
trustedProxies,
429+
clients,
381430
});
382-
if (!authResult.ok) {
431+
if (!ok) {
383432
socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
384433
socket.destroy();
385434
return;
386435
}
387436
}
388-
}
389-
if (canvasHost?.handleUpgrade(req, socket, head)) {
390-
return;
437+
if (canvasHost.handleUpgrade(req, socket, head)) {
438+
return;
439+
}
391440
}
392441
wss.handleUpgrade(req, socket, head, (ws) => {
393442
wss.emit("connection", ws, req);
394443
});
395444
})().catch(() => {
396-
try {
397-
socket.destroy();
398-
} catch {
399-
// ignore
400-
}
445+
socket.destroy();
401446
});
402447
});
403448
}

src/gateway/server-runtime-state.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ export async function createGatewayRuntimeState(params: {
107107
}
108108
}
109109

110+
const clients = new Set<GatewayWsClient>();
111+
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
112+
110113
const handleHooksRequest = createGatewayHooksRequestHandler({
111114
deps: params.deps,
112115
getHooksConfig: params.hooksConfig,
@@ -126,6 +129,7 @@ export async function createGatewayRuntimeState(params: {
126129
for (const host of bindHosts) {
127130
const httpServer = createGatewayHttpServer({
128131
canvasHost,
132+
clients,
129133
controlUiEnabled: params.controlUiEnabled,
130134
controlUiBasePath: params.controlUiBasePath,
131135
controlUiRoot: params.controlUiRoot,
@@ -168,12 +172,11 @@ export async function createGatewayRuntimeState(params: {
168172
httpServer: server,
169173
wss,
170174
canvasHost,
175+
clients,
171176
resolvedAuth: params.resolvedAuth,
172177
});
173178
}
174179

175-
const clients = new Set<GatewayWsClient>();
176-
const { broadcast, broadcastToConnIds } = createGatewayBroadcaster({ clients });
177180
const agentRunSeq = new Map<string, number>();
178181
const dedupe = new Map<string, DedupeEntry>();
179182
const chatRunState = createChatRunState();

0 commit comments

Comments
 (0)