refactor(gateway): dedupe origin seeding and plugin route auth matching
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
91
src/config/gateway-control-ui-origins.ts
Normal file
91
src/config/gateway-control-ui-origins.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
130
src/gateway/server/http-auth.ts
Normal file
130
src/gateway/server/http-auth.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
33
src/gateway/startup-control-ui-origins.ts
Normal file
33
src/gateway/startup-control-ui-origins.ts
Normal 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."
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user