diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 2743272d0..117a0e070 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -1,13 +1,12 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveGatewayPort } from "../config/config.js"; import { - appendAllowedOrigin, - buildTailnetHttpsOrigin, + maybeAddTailnetOriginToControlUiAllowedOrigins, TAILSCALE_DOCS_LINES, TAILSCALE_EXPOSURE_OPTIONS, TAILSCALE_MISSING_BIN_NOTE_LINES, } from "../gateway/gateway-config-prompts.shared.js"; -import { findTailscaleBinary, getTailnetHostname } from "../infra/tailscale.js"; +import { findTailscaleBinary } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import { note } from "../terminal/note.js"; @@ -289,27 +288,11 @@ export async function promptGatewayConfig( }, }; - // Auto-add Tailscale origin to controlUi.allowedOrigins so the Control UI - // is accessible via the Tailscale hostname without manual config. - if (tailscaleMode === "serve" || tailscaleMode === "funnel") { - const tsOrigin = await getTailnetHostname(undefined, tailscaleBin ?? undefined) - .then((host) => buildTailnetHttpsOrigin(host)) - .catch(() => null); - if (tsOrigin) { - const existing = next.gateway?.controlUi?.allowedOrigins ?? []; - const updatedOrigins = appendAllowedOrigin(existing, tsOrigin); - next = { - ...next, - gateway: { - ...next.gateway, - controlUi: { - ...next.gateway?.controlUi, - allowedOrigins: updatedOrigins, - }, - }, - }; - } - } + next = await maybeAddTailnetOriginToControlUiAllowedOrigins({ + config: next, + tailscaleMode, + tailscaleBin, + }); return { config: next, port, token: gatewayToken }; } diff --git a/src/config/gateway-control-ui-origins.ts b/src/config/gateway-control-ui-origins.ts new file mode 100644 index 000000000..9ff1fd5a1 --- /dev/null +++ b/src/config/gateway-control-ui-origins.ts @@ -0,0 +1,91 @@ +import type { OpenClawConfig } from "./config.js"; +import { DEFAULT_GATEWAY_PORT } from "./paths.js"; + +export type GatewayNonLoopbackBindMode = "lan" | "tailnet" | "custom"; + +export function isGatewayNonLoopbackBindMode(bind: unknown): bind is GatewayNonLoopbackBindMode { + return bind === "lan" || bind === "tailnet" || bind === "custom"; +} + +export function hasConfiguredControlUiAllowedOrigins(params: { + allowedOrigins: unknown; + dangerouslyAllowHostHeaderOriginFallback: unknown; +}): boolean { + if (params.dangerouslyAllowHostHeaderOriginFallback === true) { + return true; + } + return ( + Array.isArray(params.allowedOrigins) && + params.allowedOrigins.some((origin) => typeof origin === "string" && origin.trim().length > 0) + ); +} + +export function resolveGatewayPortWithDefault( + port: unknown, + fallback = DEFAULT_GATEWAY_PORT, +): number { + return typeof port === "number" && port > 0 ? port : fallback; +} + +export function buildDefaultControlUiAllowedOrigins(params: { + port: number; + bind: unknown; + customBindHost?: string; +}): string[] { + const origins = new Set([ + `http://localhost:${params.port}`, + `http://127.0.0.1:${params.port}`, + ]); + const customBindHost = params.customBindHost?.trim(); + if (params.bind === "custom" && customBindHost) { + origins.add(`http://${customBindHost}:${params.port}`); + } + return [...origins]; +} + +export function ensureControlUiAllowedOriginsForNonLoopbackBind( + config: OpenClawConfig, + opts?: { defaultPort?: number; requireControlUiEnabled?: boolean }, +): { + config: OpenClawConfig; + seededOrigins: string[] | null; + bind: GatewayNonLoopbackBindMode | null; +} { + const bind = config.gateway?.bind; + if (!isGatewayNonLoopbackBindMode(bind)) { + return { config, seededOrigins: null, bind: null }; + } + if (opts?.requireControlUiEnabled && config.gateway?.controlUi?.enabled === false) { + return { config, seededOrigins: null, bind }; + } + if ( + hasConfiguredControlUiAllowedOrigins({ + allowedOrigins: config.gateway?.controlUi?.allowedOrigins, + dangerouslyAllowHostHeaderOriginFallback: + config.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback, + }) + ) { + return { config, seededOrigins: null, bind }; + } + + const port = resolveGatewayPortWithDefault(config.gateway?.port, opts?.defaultPort); + const seededOrigins = buildDefaultControlUiAllowedOrigins({ + port, + bind, + customBindHost: config.gateway?.customBindHost, + }); + return { + config: { + ...config, + gateway: { + ...config.gateway, + controlUi: { + ...config.gateway?.controlUi, + allowedOrigins: seededOrigins, + }, + }, + }, + seededOrigins, + bind, + }; +} diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index ca663926c..3ce29ea63 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -1,3 +1,9 @@ +import { + buildDefaultControlUiAllowedOrigins, + hasConfiguredControlUiAllowedOrigins, + isGatewayNonLoopbackBindMode, + resolveGatewayPortWithDefault, +} from "./gateway-control-ui-origins.js"; import { ensureAgentEntry, ensureRecord, @@ -31,34 +37,30 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [ return; } const bind = gateway.bind; - if (bind !== "lan" && bind !== "tailnet" && bind !== "custom") { + if (!isGatewayNonLoopbackBindMode(bind)) { return; } const controlUi = getRecord(gateway.controlUi) ?? {}; - const existingOrigins = controlUi.allowedOrigins; - const hasConfiguredOrigins = - Array.isArray(existingOrigins) && - existingOrigins.some((origin) => typeof origin === "string" && origin.trim().length > 0); - if (hasConfiguredOrigins) { - return; // already configured - } - if (controlUi.dangerouslyAllowHostHeaderOriginFallback === true) { - return; // already opted into fallback - } - const port = - typeof gateway.port === "number" && gateway.port > 0 ? gateway.port : DEFAULT_GATEWAY_PORT; - const origins = new Set([`http://localhost:${port}`, `http://127.0.0.1:${port}`]); if ( - bind === "custom" && - typeof gateway.customBindHost === "string" && - gateway.customBindHost.trim() + hasConfiguredControlUiAllowedOrigins({ + allowedOrigins: controlUi.allowedOrigins, + dangerouslyAllowHostHeaderOriginFallback: + controlUi.dangerouslyAllowHostHeaderOriginFallback, + }) ) { - origins.add(`http://${gateway.customBindHost.trim()}:${port}`); + return; } - gateway.controlUi = { ...controlUi, allowedOrigins: [...origins] }; + const port = resolveGatewayPortWithDefault(gateway.port, DEFAULT_GATEWAY_PORT); + const origins = buildDefaultControlUiAllowedOrigins({ + port, + bind, + customBindHost: + typeof gateway.customBindHost === "string" ? gateway.customBindHost : undefined, + }); + gateway.controlUi = { ...controlUi, allowedOrigins: origins }; raw.gateway = gateway; changes.push( - `Seeded gateway.controlUi.allowedOrigins ${JSON.stringify([...origins])} for bind=${String(bind)}. ` + + `Seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} for bind=${String(bind)}. ` + "Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.", ); }, diff --git a/src/gateway/gateway-config-prompts.shared.ts b/src/gateway/gateway-config-prompts.shared.ts index fdc73d908..069e5c3c1 100644 --- a/src/gateway/gateway-config-prompts.shared.ts +++ b/src/gateway/gateway-config-prompts.shared.ts @@ -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 { + 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, + }, + }, + }; +} diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index c8f968a0a..95e42d6f7 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -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, - 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; - canvasCapability?: string; - malformedScopedPath?: boolean; - rateLimiter?: AuthRateLimiter; -}): Promise { - 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 { - 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, diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index a2d142c6c..8e3dba690 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -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); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 414d4db75..1ec9fc589 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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([ - `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); diff --git a/src/gateway/server/http-auth.ts b/src/gateway/server/http-auth.ts new file mode 100644 index 000000000..9d143cacd --- /dev/null +++ b/src/gateway/server/http-auth.ts @@ -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, + 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; + canvasCapability?: string; + malformedScopedPath?: boolean; + rateLimiter?: AuthRateLimiter; +}): Promise { + 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 { + 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; +} diff --git a/src/gateway/server/plugins-http.test.ts b/src/gateway/server/plugins-http.test.ts index 3e2fe65c1..0420d48e3 100644 --- a/src/gateway/server/plugins-http.test.ts +++ b/src/gateway/server/plugins-http.test.ts @@ -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); + }); }); diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 2d83dd999..793fc332d 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -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; @@ -10,6 +11,17 @@ export type PluginHttpRequestHandler = ( res: ServerResponse, ) => Promise; +type PluginHttpRouteEntry = NonNullable[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); diff --git a/src/gateway/startup-control-ui-origins.ts b/src/gateway/startup-control-ui-origins.ts new file mode 100644 index 000000000..d23f64890 --- /dev/null +++ b/src/gateway/startup-control-ui-origins.ts @@ -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; + log: { info: (msg: string) => void; warn: (msg: string) => void }; +}): Promise { + 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." + ); +} diff --git a/src/wizard/onboarding.gateway-config.ts b/src/wizard/onboarding.gateway-config.ts index cbccc5568..3eadaa5b5 100644 --- a/src/wizard/onboarding.gateway-config.ts +++ b/src/wizard/onboarding.gateway-config.ts @@ -5,14 +5,14 @@ import { } from "../commands/onboard-helpers.js"; import type { GatewayAuthChoice } from "../commands/onboard-types.js"; import type { GatewayBindMode, GatewayTailscaleMode, OpenClawConfig } from "../config/config.js"; +import { ensureControlUiAllowedOriginsForNonLoopbackBind } from "../config/gateway-control-ui-origins.js"; import { - appendAllowedOrigin, - buildTailnetHttpsOrigin, + maybeAddTailnetOriginToControlUiAllowedOrigins, TAILSCALE_DOCS_LINES, TAILSCALE_EXPOSURE_OPTIONS, TAILSCALE_MISSING_BIN_NOTE_LINES, } from "../gateway/gateway-config-prompts.shared.js"; -import { findTailscaleBinary, getTailnetHostname } from "../infra/tailscale.js"; +import { findTailscaleBinary } from "../infra/tailscale.js"; import type { RuntimeEnv } from "../runtime.js"; import { validateIPv4AddressInput } from "../shared/net/ipv4.js"; import type { @@ -51,21 +51,6 @@ type ConfigureGatewayResult = { settings: GatewayWizardSettings; }; -function buildDefaultControlUiAllowedOrigins(params: { - port: number; - bind: GatewayWizardSettings["bind"]; - customBindHost?: string; -}): string[] { - const origins = new Set([ - `http://localhost:${params.port}`, - `http://127.0.0.1:${params.port}`, - ]); - if (params.bind === "custom" && params.customBindHost) { - origins.add(`http://${params.customBindHost}:${params.port}`); - } - return [...origins]; -} - export async function configureGatewayForOnboarding( opts: ConfigureGatewayOptions, ): Promise { @@ -235,49 +220,14 @@ export async function configureGatewayForOnboarding( }, }; - const controlUiEnabled = nextConfig.gateway?.controlUi?.enabled ?? true; - const hasExplicitControlUiAllowedOrigins = - (nextConfig.gateway?.controlUi?.allowedOrigins ?? []).some( - (origin) => origin.trim().length > 0, - ) || nextConfig.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true; - if (controlUiEnabled && bind !== "loopback" && !hasExplicitControlUiAllowedOrigins) { - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - controlUi: { - ...nextConfig.gateway?.controlUi, - allowedOrigins: buildDefaultControlUiAllowedOrigins({ - port, - bind, - customBindHost, - }), - }, - }, - }; - } - - // Auto-add Tailscale origin to controlUi.allowedOrigins so the Control UI - // is accessible via the Tailscale hostname without manual config. - if (tailscaleMode === "serve" || tailscaleMode === "funnel") { - const tsOrigin = await getTailnetHostname(undefined, tailscaleBin ?? undefined) - .then((host) => buildTailnetHttpsOrigin(host)) - .catch(() => null); - if (tsOrigin) { - const existing = nextConfig.gateway?.controlUi?.allowedOrigins ?? []; - const updatedOrigins = appendAllowedOrigin(existing, tsOrigin); - nextConfig = { - ...nextConfig, - gateway: { - ...nextConfig.gateway, - controlUi: { - ...nextConfig.gateway?.controlUi, - allowedOrigins: updatedOrigins, - }, - }, - }; - } - } + nextConfig = ensureControlUiAllowedOriginsForNonLoopbackBind(nextConfig, { + requireControlUiEnabled: true, + }).config; + nextConfig = await maybeAddTailnetOriginToControlUiAllowedOrigins({ + config: nextConfig, + tailscaleMode, + tailscaleBin, + }); // If this is a new gateway setup (no existing gateway settings), start with a // denylist for high-risk node commands. Users can arm these temporarily via