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,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 };
}

View File

@@ -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<string>([
`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,
};
}

View File

@@ -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<string>([`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.",
);
},

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."
);
}

View File

@@ -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<string>([
`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<ConfigureGatewayResult> {
@@ -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