refactor(gateway): dedupe origin seeding and plugin route auth matching

This commit is contained in:
Peter Steinberger
2026-03-02 00:42:15 +00:00
parent b81e1b902d
commit cef5fae0a2
12 changed files with 411 additions and 293 deletions

View File

@@ -1,3 +1,5 @@
import type { OpenClawConfig } from "../config/config.js";
import { getTailnetHostname } from "../infra/tailscale.js";
import { isIpv6Address, parseCanonicalIpAddress } from "../shared/net/ip.js";
export const TAILSCALE_EXPOSURE_OPTIONS = [
@@ -60,3 +62,32 @@ export function appendAllowedOrigin(existing: string[] | undefined, origin: stri
}
return [...current, origin];
}
export async function maybeAddTailnetOriginToControlUiAllowedOrigins(params: {
config: OpenClawConfig;
tailscaleMode: string;
tailscaleBin?: string | null;
}): Promise<OpenClawConfig> {
if (params.tailscaleMode !== "serve" && params.tailscaleMode !== "funnel") {
return params.config;
}
const tsOrigin = await getTailnetHostname(undefined, params.tailscaleBin ?? undefined)
.then((host) => buildTailnetHttpsOrigin(host))
.catch(() => null);
if (!tsOrigin) {
return params.config;
}
const existing = params.config.gateway?.controlUi?.allowedOrigins ?? [];
const updatedOrigins = appendAllowedOrigin(existing, tsOrigin);
return {
...params.config,
gateway: {
...params.config.gateway,
controlUi: {
...params.config.gateway?.controlUi,
allowedOrigins: updatedOrigins,
},
},
};
}

View File

@@ -8,12 +8,7 @@ import { createServer as createHttpsServer } from "node:https";
import type { TlsOptions } from "node:tls";
import type { WebSocketServer } from "ws";
import { resolveAgentAvatar } from "../agents/identity-avatar.js";
import {
A2UI_PATH,
CANVAS_HOST_PATH,
CANVAS_WS_PATH,
handleA2uiHttpRequest,
} from "../canvas-host/a2ui.js";
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";
@@ -25,13 +20,8 @@ import {
normalizeRateLimitClientIp,
type AuthRateLimiter,
} from "./auth-rate-limit.js";
import {
authorizeHttpGatewayConnect,
isLocalDirectRequest,
type GatewayAuthResult,
type ResolvedGatewayAuth,
} from "./auth.js";
import { CANVAS_CAPABILITY_TTL_MS, normalizeCanvasScopedUrl } from "./canvas-capability.js";
import { type GatewayAuthResult, type ResolvedGatewayAuth } from "./auth.js";
import { normalizeCanvasScopedUrl } from "./canvas-capability.js";
import {
handleControlUiAvatarRequest,
handleControlUiHttpRequest,
@@ -56,11 +46,14 @@ import {
resolveHookDeliver,
} from "./hooks.js";
import { sendGatewayAuthFailure, setDefaultSecurityHeaders } from "./http-common.js";
import { getBearerToken } from "./http-utils.js";
import { handleOpenAiHttpRequest } from "./openai-http.js";
import { handleOpenResponsesHttpRequest } from "./openresponses-http.js";
import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "./protocol/client-info.js";
import { isProtectedPluginRoutePath } from "./security-path.js";
import {
authorizeCanvasRequest,
enforcePluginRouteGatewayAuth,
isCanvasPath,
} from "./server/http-auth.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import { handleToolsInvokeHttpRequest } from "./tools-invoke-http.js";
@@ -80,121 +73,6 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body));
}
function isCanvasPath(pathname: string): boolean {
return (
pathname === A2UI_PATH ||
pathname.startsWith(`${A2UI_PATH}/`) ||
pathname === CANVAS_HOST_PATH ||
pathname.startsWith(`${CANVAS_HOST_PATH}/`) ||
pathname === CANVAS_WS_PATH
);
}
function isNodeWsClient(client: GatewayWsClient): boolean {
if (client.connect.role === "node") {
return true;
}
return normalizeGatewayClientMode(client.connect.client.mode) === GATEWAY_CLIENT_MODES.NODE;
}
function hasAuthorizedNodeWsClientForCanvasCapability(
clients: Set<GatewayWsClient>,
capability: string,
): boolean {
const nowMs = Date.now();
for (const client of clients) {
if (!isNodeWsClient(client)) {
continue;
}
if (!client.canvasCapability || !client.canvasCapabilityExpiresAtMs) {
continue;
}
if (client.canvasCapabilityExpiresAtMs <= nowMs) {
continue;
}
if (safeEqualSecret(client.canvasCapability, capability)) {
// Sliding expiration while the connected node keeps using canvas.
client.canvasCapabilityExpiresAtMs = nowMs + CANVAS_CAPABILITY_TTL_MS;
return true;
}
}
return false;
}
async function authorizeCanvasRequest(params: {
req: IncomingMessage;
auth: ResolvedGatewayAuth;
trustedProxies: string[];
allowRealIpFallback: boolean;
clients: Set<GatewayWsClient>;
canvasCapability?: string;
malformedScopedPath?: boolean;
rateLimiter?: AuthRateLimiter;
}): Promise<GatewayAuthResult> {
const {
req,
auth,
trustedProxies,
allowRealIpFallback,
clients,
canvasCapability,
malformedScopedPath,
rateLimiter,
} = params;
if (malformedScopedPath) {
return { ok: false, reason: "unauthorized" };
}
if (isLocalDirectRequest(req, trustedProxies, allowRealIpFallback)) {
return { ok: true };
}
let lastAuthFailure: GatewayAuthResult | null = null;
const token = getBearerToken(req);
if (token) {
const authResult = await authorizeHttpGatewayConnect({
auth: { ...auth, allowTailscale: false },
connectAuth: { token, password: token },
req,
trustedProxies,
allowRealIpFallback,
rateLimiter,
});
if (authResult.ok) {
return authResult;
}
lastAuthFailure = authResult;
}
if (canvasCapability && hasAuthorizedNodeWsClientForCanvasCapability(clients, canvasCapability)) {
return { ok: true };
}
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
}
async function enforcePluginRouteGatewayAuth(params: {
req: IncomingMessage;
res: ServerResponse;
auth: ResolvedGatewayAuth;
trustedProxies: string[];
allowRealIpFallback: boolean;
rateLimiter?: AuthRateLimiter;
}): Promise<boolean> {
const token = getBearerToken(params.req);
const authResult = await authorizeHttpGatewayConnect({
auth: params.auth,
connectAuth: token ? { token, password: token } : null,
req: params.req,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
rateLimiter: params.rateLimiter,
});
if (!authResult.ok) {
sendGatewayAuthFailure(params.res, authResult);
return false;
}
return true;
}
function writeUpgradeAuthFailure(
socket: { write: (chunk: string) => void },
auth: GatewayAuthResult,

View File

@@ -12,7 +12,6 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
import type { ControlUiRootState } from "./control-ui.js";
import type { HooksConfigResolved } from "./hooks.js";
import { isLoopbackHost, resolveGatewayListenHosts } from "./net.js";
import { isProtectedPluginRoutePath } from "./security-path.js";
import {
createGatewayBroadcaster,
type GatewayBroadcastFn,
@@ -30,7 +29,7 @@ import { createGatewayHooksRequestHandler } from "./server/hooks.js";
import { listenGatewayHttpServer } from "./server/http-listen.js";
import {
createGatewayPluginRequestHandler,
isRegisteredPluginHttpRoutePath,
shouldEnforceGatewayAuthForPluginPath,
} from "./server/plugins-http.js";
import type { GatewayTlsRuntime } from "./server/tls.js";
import type { GatewayWsClient } from "./server/ws-types.js";
@@ -120,10 +119,7 @@ export async function createGatewayRuntimeState(params: {
log: params.logPlugins,
});
const shouldEnforcePluginGatewayAuth = (requestPath: string): boolean => {
if (isProtectedPluginRoutePath(requestPath)) {
return true;
}
return isRegisteredPluginHttpRoutePath(params.pluginRegistry, requestPath);
return shouldEnforceGatewayAuthForPluginPath(params.pluginRegistry, requestPath);
};
const bindHosts = await resolveGatewayListenHosts(params.bindHost);

View File

@@ -18,7 +18,6 @@ import {
readConfigFileSnapshot,
writeConfigFile,
} from "../config/config.js";
import { DEFAULT_GATEWAY_PORT } from "../config/paths.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { resolveMainSessionKey } from "../config/sessions.js";
import { clearAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
@@ -101,6 +100,7 @@ import {
} from "./server/health-state.js";
import { loadGatewayTlsRuntime } from "./server/tls.js";
import { ensureGatewayStartupAuth } from "./startup-auth.js";
import { maybeSeedControlUiAllowedOriginsAtStartup } from "./startup-control-ui-origins.js";
export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js";
@@ -379,53 +379,12 @@ export async function startGatewayServer(
() => getTotalQueueSize() + getTotalPendingReplies() + getActiveEmbeddedRunCount(),
);
// Unconditional startup migration: seed gateway.controlUi.allowedOrigins for existing
// bind=lan/custom installs that upgraded to v2026.2.26+ without the required origins set.
// This runs regardless of whether legacy-key issues exist — the affected config is
// schema-valid (no legacy keys), so it is never caught by the legacyIssues gate above.
// Without this guard the gateway would proceed to resolveGatewayRuntimeConfig and throw,
// causing a systemd crash-loop with no recovery path (issue #29385).
const controlUiBind = cfgAtStart.gateway?.bind;
const isNonLoopbackBind =
controlUiBind === "lan" || controlUiBind === "tailnet" || controlUiBind === "custom";
const hasControlUiOrigins = (cfgAtStart.gateway?.controlUi?.allowedOrigins ?? []).some(
(origin) => typeof origin === "string" && origin.trim().length > 0,
);
const hasControlUiFallback =
cfgAtStart.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
if (isNonLoopbackBind && !hasControlUiOrigins && !hasControlUiFallback) {
const bindPort =
typeof cfgAtStart.gateway?.port === "number" && cfgAtStart.gateway.port > 0
? cfgAtStart.gateway.port
: DEFAULT_GATEWAY_PORT;
const seededOrigins = new Set<string>([
`http://localhost:${bindPort}`,
`http://127.0.0.1:${bindPort}`,
]);
const customBindHost = cfgAtStart.gateway?.customBindHost?.trim();
if (controlUiBind === "custom" && customBindHost) {
seededOrigins.add(`http://${customBindHost}:${bindPort}`);
}
cfgAtStart = {
...cfgAtStart,
gateway: {
...cfgAtStart.gateway,
controlUi: {
...cfgAtStart.gateway?.controlUi,
allowedOrigins: [...seededOrigins],
},
},
};
try {
await writeConfigFile(cfgAtStart);
log.info(
`gateway: seeded gateway.controlUi.allowedOrigins ${JSON.stringify([...seededOrigins])} for bind=${controlUiBind} (required since v2026.2.26; see issue #29385). Add other origins to gateway.controlUi.allowedOrigins if needed.`,
);
} catch (err) {
log.warn(
`gateway: failed to persist gateway.controlUi.allowedOrigins seed: ${String(err)}. The gateway will start with the in-memory value but config was not saved.`,
);
}
}
// non-loopback installs that upgraded to v2026.2.26+ without required origins.
cfgAtStart = await maybeSeedControlUiAllowedOriginsAtStartup({
config: cfgAtStart,
writeConfig: writeConfigFile,
log,
});
initSubagentRegistry();
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);

View File

@@ -0,0 +1,130 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH } from "../../canvas-host/a2ui.js";
import { safeEqualSecret } from "../../security/secret-equal.js";
import type { AuthRateLimiter } from "../auth-rate-limit.js";
import {
authorizeHttpGatewayConnect,
isLocalDirectRequest,
type GatewayAuthResult,
type ResolvedGatewayAuth,
} from "../auth.js";
import { CANVAS_CAPABILITY_TTL_MS } from "../canvas-capability.js";
import { sendGatewayAuthFailure } from "../http-common.js";
import { getBearerToken } from "../http-utils.js";
import { GATEWAY_CLIENT_MODES, normalizeGatewayClientMode } from "../protocol/client-info.js";
import type { GatewayWsClient } from "./ws-types.js";
export function isCanvasPath(pathname: string): boolean {
return (
pathname === A2UI_PATH ||
pathname.startsWith(`${A2UI_PATH}/`) ||
pathname === CANVAS_HOST_PATH ||
pathname.startsWith(`${CANVAS_HOST_PATH}/`) ||
pathname === CANVAS_WS_PATH
);
}
function isNodeWsClient(client: GatewayWsClient): boolean {
if (client.connect.role === "node") {
return true;
}
return normalizeGatewayClientMode(client.connect.client.mode) === GATEWAY_CLIENT_MODES.NODE;
}
function hasAuthorizedNodeWsClientForCanvasCapability(
clients: Set<GatewayWsClient>,
capability: string,
): boolean {
const nowMs = Date.now();
for (const client of clients) {
if (!isNodeWsClient(client)) {
continue;
}
if (!client.canvasCapability || !client.canvasCapabilityExpiresAtMs) {
continue;
}
if (client.canvasCapabilityExpiresAtMs <= nowMs) {
continue;
}
if (safeEqualSecret(client.canvasCapability, capability)) {
// Sliding expiration while the connected node keeps using canvas.
client.canvasCapabilityExpiresAtMs = nowMs + CANVAS_CAPABILITY_TTL_MS;
return true;
}
}
return false;
}
export async function authorizeCanvasRequest(params: {
req: IncomingMessage;
auth: ResolvedGatewayAuth;
trustedProxies: string[];
allowRealIpFallback: boolean;
clients: Set<GatewayWsClient>;
canvasCapability?: string;
malformedScopedPath?: boolean;
rateLimiter?: AuthRateLimiter;
}): Promise<GatewayAuthResult> {
const {
req,
auth,
trustedProxies,
allowRealIpFallback,
clients,
canvasCapability,
malformedScopedPath,
rateLimiter,
} = params;
if (malformedScopedPath) {
return { ok: false, reason: "unauthorized" };
}
if (isLocalDirectRequest(req, trustedProxies, allowRealIpFallback)) {
return { ok: true };
}
let lastAuthFailure: GatewayAuthResult | null = null;
const token = getBearerToken(req);
if (token) {
const authResult = await authorizeHttpGatewayConnect({
auth: { ...auth, allowTailscale: false },
connectAuth: { token, password: token },
req,
trustedProxies,
allowRealIpFallback,
rateLimiter,
});
if (authResult.ok) {
return authResult;
}
lastAuthFailure = authResult;
}
if (canvasCapability && hasAuthorizedNodeWsClientForCanvasCapability(clients, canvasCapability)) {
return { ok: true };
}
return lastAuthFailure ?? { ok: false, reason: "unauthorized" };
}
export async function enforcePluginRouteGatewayAuth(params: {
req: IncomingMessage;
res: ServerResponse;
auth: ResolvedGatewayAuth;
trustedProxies: string[];
allowRealIpFallback: boolean;
rateLimiter?: AuthRateLimiter;
}): Promise<boolean> {
const token = getBearerToken(params.req);
const authResult = await authorizeHttpGatewayConnect({
auth: params.auth,
connectAuth: token ? { token, password: token } : null,
req: params.req,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
rateLimiter: params.rateLimiter,
});
if (!authResult.ok) {
sendGatewayAuthFailure(params.res, authResult);
return false;
}
return true;
}

View File

@@ -5,6 +5,7 @@ import { createTestRegistry } from "./__tests__/test-utils.js";
import {
createGatewayPluginRequestHandler,
isRegisteredPluginHttpRoutePath,
shouldEnforceGatewayAuthForPluginPath,
} from "./plugins-http.js";
describe("createGatewayPluginRequestHandler", () => {
@@ -72,6 +73,35 @@ describe("createGatewayPluginRequestHandler", () => {
expect(fallback).not.toHaveBeenCalled();
});
it("matches canonicalized route variants before generic handlers", async () => {
const routeHandler = vi.fn(async (_req, res: ServerResponse) => {
res.statusCode = 200;
});
const fallback = vi.fn(async () => true);
const handler = createGatewayPluginRequestHandler({
registry: createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/api/demo",
handler: routeHandler,
source: "route",
},
],
httpHandlers: [{ pluginId: "fallback", handler: fallback, source: "fallback" }],
}),
log: { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
>[0]["log"],
});
const { res } = makeMockHttpResponse();
const handled = await handler({ url: "/API//demo" } as IncomingMessage, res);
expect(handled).toBe(true);
expect(routeHandler).toHaveBeenCalledTimes(1);
expect(fallback).not.toHaveBeenCalled();
});
it("logs and responds with 500 when a handler throws", async () => {
const log = { warn: vi.fn() } as unknown as Parameters<
typeof createGatewayPluginRequestHandler
@@ -132,4 +162,20 @@ describe("plugin HTTP registry helpers", () => {
expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true);
expect(isRegisteredPluginHttpRoutePath(registry, "/api/%2564emo")).toBe(true);
});
it("enforces auth for protected and registered plugin routes", () => {
const registry = createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/api/demo",
handler: () => {},
source: "route",
},
],
});
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api//demo")).toBe(true);
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/api/channels/status")).toBe(true);
expect(shouldEnforceGatewayAuthForPluginPath(registry, "/not-plugin")).toBe(false);
});
});

View File

@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import type { createSubsystemLogger } from "../../logging/subsystem.js";
import type { PluginRegistry } from "../../plugins/registry.js";
import { canonicalizePathVariant } from "../security-path.js";
import { isProtectedPluginRoutePath } from "../security-path.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
@@ -10,6 +11,17 @@ export type PluginHttpRequestHandler = (
res: ServerResponse,
) => Promise<boolean>;
type PluginHttpRouteEntry = NonNullable<PluginRegistry["httpRoutes"]>[number];
export function findRegisteredPluginHttpRoute(
registry: PluginRegistry,
pathname: string,
): PluginHttpRouteEntry | undefined {
const canonicalPath = canonicalizePathVariant(pathname);
const routes = registry.httpRoutes ?? [];
return routes.find((entry) => canonicalizePathVariant(entry.path) === canonicalPath);
}
// Only checks specific routes registered via registerHttpRoute, not wildcard handlers
// registered via registerHttpHandler. Wildcard handlers (e.g., webhooks) implement
// their own signature-based auth and are handled separately in the auth enforcement logic.
@@ -17,9 +29,16 @@ export function isRegisteredPluginHttpRoutePath(
registry: PluginRegistry,
pathname: string,
): boolean {
const canonicalPath = canonicalizePathVariant(pathname);
const routes = registry.httpRoutes ?? [];
return routes.some((entry) => canonicalizePathVariant(entry.path) === canonicalPath);
return findRegisteredPluginHttpRoute(registry, pathname) !== undefined;
}
export function shouldEnforceGatewayAuthForPluginPath(
registry: PluginRegistry,
pathname: string,
): boolean {
return (
isProtectedPluginRoutePath(pathname) || isRegisteredPluginHttpRoutePath(registry, pathname)
);
}
export function createGatewayPluginRequestHandler(params: {
@@ -36,7 +55,7 @@ export function createGatewayPluginRequestHandler(params: {
if (routes.length > 0) {
const url = new URL(req.url ?? "/", "http://localhost");
const route = routes.find((entry) => entry.path === url.pathname);
const route = findRegisteredPluginHttpRoute(registry, url.pathname);
if (route) {
try {
await route.handler(req, res);

View File

@@ -0,0 +1,33 @@
import type { OpenClawConfig } from "../config/config.js";
import {
ensureControlUiAllowedOriginsForNonLoopbackBind,
type GatewayNonLoopbackBindMode,
} from "../config/gateway-control-ui-origins.js";
export async function maybeSeedControlUiAllowedOriginsAtStartup(params: {
config: OpenClawConfig;
writeConfig: (config: OpenClawConfig) => Promise<void>;
log: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<OpenClawConfig> {
const seeded = ensureControlUiAllowedOriginsForNonLoopbackBind(params.config);
if (!seeded.seededOrigins || !seeded.bind) {
return params.config;
}
try {
await params.writeConfig(seeded.config);
params.log.info(buildSeededOriginsInfoLog(seeded.seededOrigins, seeded.bind));
} catch (err) {
params.log.warn(
`gateway: failed to persist gateway.controlUi.allowedOrigins seed: ${String(err)}. The gateway will start with the in-memory value but config was not saved.`,
);
}
return seeded.config;
}
function buildSeededOriginsInfoLog(origins: string[], bind: GatewayNonLoopbackBindMode): string {
return (
`gateway: seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} ` +
`for bind=${bind} (required since v2026.2.26; see issue #29385). ` +
"Add other origins to gateway.controlUi.allowedOrigins if needed."
);
}