fix(gateway): require auth for canvas host and a2ui assets (#9518) (thanks @coygeek)

This commit is contained in:
George Pickett
2026-02-05 16:22:34 -08:00
parent 47538bca4d
commit a459e237e8
9 changed files with 314 additions and 33 deletions

View File

@@ -9,6 +9,7 @@ import {
import { createServer as createHttpsServer } from "node:https";
import type { CanvasHostHandler } from "../canvas-host/server.js";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
import {
A2UI_PATH,
@@ -18,7 +19,7 @@ import {
} from "../canvas-host/a2ui.js";
import { loadConfig } from "../config/config.js";
import { handleSlackHttpRequest } from "../slack/http/index.js";
import { authorizeGatewayConnect } from "./auth.js";
import { authorizeGatewayConnect, isLocalDirectRequest, type ResolvedGatewayAuth } from "./auth.js";
import {
handleControlUiAvatarRequest,
handleControlUiHttpRequest,
@@ -38,7 +39,8 @@ import {
resolveHookDeliver,
} from "./hooks.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 { handleOpenResponsesHttpRequest } from "./openresponses-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 function createHooksRequestHandler(
@@ -226,6 +273,7 @@ export function createHooksRequestHandler(
export function createGatewayHttpServer(opts: {
canvasHost: CanvasHostHandler | null;
clients: Set<GatewayWsClient>;
controlUiEnabled: boolean;
controlUiBasePath: string;
controlUiRoot?: ControlUiRootState;
@@ -234,11 +282,12 @@ export function createGatewayHttpServer(opts: {
openResponsesConfig?: import("../config/types.gateway.js").GatewayHttpResponsesConfig;
handleHooksRequest: HooksRequestHandler;
handlePluginRequest?: HooksRequestHandler;
resolvedAuth: import("./auth.js").ResolvedGatewayAuth;
resolvedAuth: ResolvedGatewayAuth;
tlsOptions?: TlsOptions;
}): HttpServer {
const {
canvasHost,
clients,
controlUiEnabled,
controlUiBasePath,
controlUiRoot,
@@ -305,16 +354,15 @@ export function createGatewayHttpServer(opts: {
}
}
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)) {
const token = getBearerToken(req);
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: token ? { token, password: token } : null,
const ok = await authorizeCanvasRequest({
req,
auth: resolvedAuth,
trustedProxies,
clients,
});
if (!authResult.ok) {
if (!ok) {
sendUnauthorized(res);
return;
}
@@ -363,41 +411,38 @@ export function attachGatewayUpgradeHandler(opts: {
httpServer: HttpServer;
wss: WebSocketServer;
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) => {
void (async () => {
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) {
const configSnapshot = loadConfig();
const token = getBearerToken(req);
const authResult = await authorizeGatewayConnect({
auth: resolvedAuth,
connectAuth: token ? { token, password: token } : null,
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
const ok = await authorizeCanvasRequest({
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.destroy();
return;
}
}
}
if (canvasHost?.handleUpgrade(req, socket, head)) {
return;
if (canvasHost.handleUpgrade(req, socket, head)) {
return;
}
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
})().catch(() => {
try {
socket.destroy();
} catch {
// ignore
}
socket.destroy();
});
});
}