fix(gateway): land access/auth/config migration cluster

Land #28960 by @Glucksberg (Tailscale origin auto-allowlist).
Land #29394 by @synchronic1 (allowedOrigins upgrade migration).
Land #29198 by @Mariana-Codebase (plugin HTTP auth guard + route precedence).
Land #30910 by @liuxiaopai-ai (tailscale bind/config.patch guard).

Co-authored-by: Glucksberg <markuscontasul@gmail.com>
Co-authored-by: synchronic1 <synchronic1@users.noreply.github.com>
Co-authored-by: Mariana Sinisterra <mariana.data@outlook.com>
Co-authored-by: liuxiaopai-ai <73659136+liuxiaopai-ai@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-03-02 00:05:48 +00:00
parent 8e6b3ade3e
commit 53d10f8688
18 changed files with 876 additions and 37 deletions

View File

@@ -127,6 +127,10 @@ Docs: https://docs.openclaw.ai
- Gateway/Control UI API routing: when `gateway.controlUi.basePath` is unset (default), stop serving Control UI SPA HTML for `/api` and `/api/*` so API paths fall through to normal gateway handlers/404 responses instead of `index.html`. (#30333) Fixes #30295. thanks @Sid-Qin.
- Cron/One-shot reliability: retry transient one-shot failures with bounded backoff and configurable retry policy before disabling. (#24435) Thanks @hugenshen.
- Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks @MoerAI.
- Gateway/Tailscale onboarding origin allowlist: auto-add the detected Tailnet HTTPS origin during interactive configure/onboarding flows (including IPv6-safe origin formatting and binary-path reuse), so Tailscale serve/funnel Control UI access works without manual `allowedOrigins` edits. Landed from contributor PR #28960 by @Glucksberg. Thanks @Glucksberg.
- Gateway/Upgrade migration for Control UI origins: seed `gateway.controlUi.allowedOrigins` on startup for legacy non-loopback configs (`lan`/`tailnet`/`custom`) when origins are missing or blank, preventing post-upgrade crash loops while preserving explicit existing policy. Landed from contributor PR #29394 by @synchronic1. Thanks @synchronic1.
- Gateway/Plugin HTTP auth hardening: require gateway auth for protected plugin paths and explicit `registerHttpRoute` paths (while preserving wildcard-handler behavior for signature-auth webhooks), and run plugin handlers after built-in handlers for deterministic route precedence. Landed from contributor PR #29198 by @Mariana-Codebase. Thanks @Mariana-Codebase.
- Gateway/Config patch guard: reject `config.patch` updates that set non-loopback `gateway.bind` while `gateway.tailscale.mode` is `serve`/`funnel`, preventing restart crash loops from invalid bind/tailscale combinations. Landed from contributor PR #30910 by @liuxiaopai-ai. Thanks @liuxiaopai-ai.
- Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks @ningding97.
- Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks @ningding97.
- File tools/tilde paths: expand `~/...` against the user home directory before workspace-root checks in host file read/write/edit paths, while preserving root-boundary enforcement so outside-root targets remain blocked. (#29779) Thanks @Glucksberg.

View File

@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
const mocks = vi.hoisted(() => ({
@@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => ({
buildGatewayAuthConfig: vi.fn(),
note: vi.fn(),
randomToken: vi.fn(),
getTailnetHostname: vi.fn(),
}));
vi.mock("../config/config.js", async (importActual) => {
@@ -35,6 +37,7 @@ vi.mock("./configure.gateway-auth.js", () => ({
vi.mock("../infra/tailscale.js", () => ({
findTailscaleBinary: vi.fn(async () => undefined),
getTailnetHostname: mocks.getTailnetHostname,
}));
vi.mock("./onboard-helpers.js", async (importActual) => {
@@ -58,6 +61,7 @@ function makeRuntime(): RuntimeEnv {
async function runGatewayPrompt(params: {
selectQueue: string[];
textQueue: Array<string | undefined>;
baseConfig?: OpenClawConfig;
randomToken?: string;
confirmResult?: boolean;
authConfigFactory?: (input: Record<string, unknown>) => Record<string, unknown>;
@@ -72,7 +76,7 @@ async function runGatewayPrompt(params: {
params.authConfigFactory ? params.authConfigFactory(input as Record<string, unknown>) : input,
);
const result = await promptGatewayConfig({}, makeRuntime());
const result = await promptGatewayConfig(params.baseConfig ?? {}, makeRuntime());
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
return { result, call };
}
@@ -154,4 +158,78 @@ describe("promptGatewayConfig", () => {
expect(result.config.gateway?.tailscale?.mode).toBe("off");
expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false);
});
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => {
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const { result } = await runGatewayPrompt({
// bind=loopback, auth=token, tailscale=serve
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
});
expect(result.config.gateway?.controlUi?.allowedOrigins).toContain(
"https://my-host.tail1234.ts.net",
);
});
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale funnel is enabled", async () => {
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const { result } = await runGatewayPrompt({
// bind=loopback, auth=password (funnel requires password), tailscale=funnel
selectQueue: ["loopback", "password", "funnel"],
textQueue: ["18789", "my-password"],
confirmResult: true,
authConfigFactory: ({ mode, password }) => ({ mode, password }),
});
expect(result.config.gateway?.controlUi?.allowedOrigins).toContain(
"https://my-host.tail1234.ts.net",
);
});
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
});
expect(result.config.gateway?.controlUi?.allowedOrigins).toBeUndefined();
});
it("does not duplicate Tailscale origin if already present", async () => {
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const { result } = await runGatewayPrompt({
baseConfig: {
gateway: {
controlUi: {
allowedOrigins: ["HTTPS://MY-HOST.TAIL1234.TS.NET"],
},
},
},
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
});
const origins = result.config.gateway?.controlUi?.allowedOrigins ?? [];
const tsOriginCount = origins.filter(
(origin) => origin.toLowerCase() === "https://my-host.tail1234.ts.net",
).length;
expect(tsOriginCount).toBe(1);
});
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::12");
const { result } = await runGatewayPrompt({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", "my-token"],
confirmResult: true,
authConfigFactory: ({ mode, token }) => ({ mode, token }),
});
expect(result.config.gateway?.controlUi?.allowedOrigins).toContain(
"https://[fd7a:115c:a1e0::12]",
);
});
});

View File

@@ -1,11 +1,13 @@
import type { OpenClawConfig } from "../config/config.js";
import { resolveGatewayPort } from "../config/config.js";
import {
appendAllowedOrigin,
buildTailnetHttpsOrigin,
TAILSCALE_DOCS_LINES,
TAILSCALE_EXPOSURE_OPTIONS,
TAILSCALE_MISSING_BIN_NOTE_LINES,
} from "../gateway/gateway-config-prompts.shared.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import { findTailscaleBinary, getTailnetHostname } from "../infra/tailscale.js";
import type { RuntimeEnv } from "../runtime.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import { note } from "../terminal/note.js";
@@ -111,8 +113,10 @@ export async function promptGatewayConfig(
);
// Detect Tailscale binary before proceeding with serve/funnel setup.
// Persist the path so getTailnetHostname can reuse it for origin injection.
let tailscaleBin: string | null = null;
if (tailscaleMode !== "off") {
const tailscaleBin = await findTailscaleBinary();
tailscaleBin = await findTailscaleBinary();
if (!tailscaleBin) {
note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning");
}
@@ -285,5 +289,27 @@ 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,
},
},
};
}
}
return { config: next, port, token: gatewayToken };
}

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { validateConfigObject } from "./config.js";
describe("gateway tailscale bind validation", () => {
it("accepts loopback bind when tailscale serve/funnel is enabled", () => {
const serveRes = validateConfigObject({
gateway: {
bind: "loopback",
tailscale: { mode: "serve" },
},
});
expect(serveRes.ok).toBe(true);
const funnelRes = validateConfigObject({
gateway: {
bind: "loopback",
tailscale: { mode: "funnel" },
},
});
expect(funnelRes.ok).toBe(true);
});
it("accepts custom loopback bind host with tailscale serve/funnel", () => {
const res = validateConfigObject({
gateway: {
bind: "custom",
customBindHost: "127.0.0.1",
tailscale: { mode: "serve" },
},
});
expect(res.ok).toBe(true);
});
it("rejects IPv6 custom bind host for tailscale serve/funnel", () => {
const res = validateConfigObject({
gateway: {
bind: "custom",
customBindHost: "::1",
tailscale: { mode: "serve" },
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues.some((issue) => issue.path === "gateway.bind")).toBe(true);
}
});
it("rejects non-loopback bind when tailscale serve/funnel is enabled", () => {
const lanRes = validateConfigObject({
gateway: {
bind: "lan",
tailscale: { mode: "serve" },
},
});
expect(lanRes.ok).toBe(false);
if (!lanRes.ok) {
expect(lanRes.issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: "gateway.bind",
message: expect.stringContaining("gateway.bind must resolve to loopback"),
}),
]),
);
}
const customRes = validateConfigObject({
gateway: {
bind: "custom",
customBindHost: "10.0.0.5",
tailscale: { mode: "funnel" },
},
});
expect(customRes.ok).toBe(false);
if (!customRes.ok) {
expect(customRes.issues.some((issue) => issue.path === "gateway.bind")).toBe(true);
}
});
});

View File

@@ -365,7 +365,11 @@ describe("legacy config detection", () => {
gateway: { bind: "tailnet" as const },
});
expect(res.changes).not.toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
expect(res.config).toBeNull();
expect(res.config?.gateway?.bind).toBe("tailnet");
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
"http://localhost:18789",
"http://127.0.0.1:18789",
]);
const validated = validateConfigObject({ gateway: { bind: "tailnet" as const } });
expect(validated.ok).toBe(true);

View File

@@ -104,3 +104,113 @@ describe("legacy migrate mention routing", () => {
).toBeUndefined();
});
});
describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => {
it("seeds allowedOrigins for bind=lan with no existing controlUi config", () => {
const res = migrateLegacyConfig({
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok" },
},
});
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
"http://localhost:18789",
"http://127.0.0.1:18789",
]);
expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true);
expect(res.changes.some((c) => c.includes("bind=lan"))).toBe(true);
});
it("seeds allowedOrigins using configured port", () => {
const res = migrateLegacyConfig({
gateway: {
bind: "lan",
port: 9000,
auth: { mode: "token", token: "tok" },
},
});
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
"http://localhost:9000",
"http://127.0.0.1:9000",
]);
});
it("seeds allowedOrigins including custom bind host for bind=custom", () => {
const res = migrateLegacyConfig({
gateway: {
bind: "custom",
customBindHost: "192.168.1.100",
auth: { mode: "token", token: "tok" },
},
});
expect(res.config?.gateway?.controlUi?.allowedOrigins).toContain("http://192.168.1.100:18789");
expect(res.config?.gateway?.controlUi?.allowedOrigins).toContain("http://localhost:18789");
});
it("does not overwrite existing allowedOrigins — returns null (no migration needed)", () => {
// When allowedOrigins already exists, the migration is a no-op.
// applyLegacyMigrations returns next=null when changes.length===0, so config is null.
const res = migrateLegacyConfig({
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok" },
controlUi: { allowedOrigins: ["https://control.example.com"] },
},
});
expect(res.config).toBeNull();
expect(res.changes).toHaveLength(0);
});
it("does not migrate when dangerouslyAllowHostHeaderOriginFallback is set — returns null", () => {
const res = migrateLegacyConfig({
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok" },
controlUi: { dangerouslyAllowHostHeaderOriginFallback: true },
},
});
expect(res.config).toBeNull();
expect(res.changes).toHaveLength(0);
});
it("seeds allowedOrigins when existing entries are blank strings", () => {
const res = migrateLegacyConfig({
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok" },
controlUi: { allowedOrigins: ["", " "] },
},
});
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
"http://localhost:18789",
"http://127.0.0.1:18789",
]);
expect(res.changes.some((c) => c.includes("gateway.controlUi.allowedOrigins"))).toBe(true);
});
it("does not migrate loopback bind — returns null", () => {
const res = migrateLegacyConfig({
gateway: {
bind: "loopback",
auth: { mode: "token", token: "tok" },
},
});
expect(res.config).toBeNull();
expect(res.changes).toHaveLength(0);
});
it("preserves existing controlUi fields when seeding allowedOrigins", () => {
const res = migrateLegacyConfig({
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok" },
controlUi: { basePath: "/app" },
},
});
expect(res.config?.gateway?.controlUi?.basePath).toBe("/app");
expect(res.config?.gateway?.controlUi?.allowedOrigins).toEqual([
"http://localhost:18789",
"http://127.0.0.1:18789",
]);
});
});

View File

@@ -8,12 +8,61 @@ import {
mergeMissing,
resolveDefaultAgentIdFromRaw,
} from "./legacy.shared.js";
import { DEFAULT_GATEWAY_PORT } from "./paths.js";
// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
// v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the
// host-header fallback flag) for any non-loopback bind. The onboarding wizard was updated
// to seed this for new installs, but existing bind=lan/bind=custom installs that upgrade
// crash-loop immediately on next startup with no recovery path (issue #29385).
//
// This migration runs on every gateway start via migrateLegacyConfig → applyLegacyMigrations
// and writes the seeded origins to disk before the startup guard fires, preventing the loop.
id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback",
describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs",
apply: (raw, changes) => {
const gateway = getRecord(raw.gateway);
if (!gateway) {
return;
}
const bind = gateway.bind;
if (bind !== "lan" && bind !== "tailnet" && bind !== "custom") {
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()
) {
origins.add(`http://${gateway.customBindHost.trim()}:${port}`);
}
gateway.controlUi = { ...controlUi, allowedOrigins: [...origins] };
raw.gateway = gateway;
changes.push(
`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.",
);
},
},
{
id: "memorySearch->agents.defaults.memorySearch",
describe: "Move top-level memorySearch to agents.defaults.memorySearch",

View File

@@ -15,6 +15,7 @@ import {
isPathWithinRoot,
isWindowsAbsolutePath,
} from "../shared/avatar-policy.js";
import { isCanonicalDottedDecimalIPv4, isLoopbackIpAddress } from "../shared/net/ip.js";
import { isRecord } from "../utils.js";
import { findDuplicateAgentDirs, formatDuplicateAgentDirError } from "./agent-dirs.js";
import { applyAgentDefaults, applyModelDefaults, applySessionDefaults } from "./defaults.js";
@@ -80,6 +81,33 @@ function validateIdentityAvatar(config: OpenClawConfig): ConfigValidationIssue[]
return issues;
}
function validateGatewayTailscaleBind(config: OpenClawConfig): ConfigValidationIssue[] {
const tailscaleMode = config.gateway?.tailscale?.mode ?? "off";
if (tailscaleMode !== "serve" && tailscaleMode !== "funnel") {
return [];
}
const bindMode = config.gateway?.bind ?? "loopback";
if (bindMode === "loopback") {
return [];
}
const customBindHost = config.gateway?.customBindHost;
if (
bindMode === "custom" &&
isCanonicalDottedDecimalIPv4(customBindHost) &&
isLoopbackIpAddress(customBindHost)
) {
return [];
}
return [
{
path: "gateway.bind",
message:
`gateway.bind must resolve to loopback when gateway.tailscale.mode=${tailscaleMode} ` +
'(use gateway.bind="loopback" or gateway.bind="custom" with gateway.customBindHost="127.0.0.1")',
},
];
}
/**
* Validates config without applying runtime defaults.
* Use this when you need the raw validated config (e.g., for writing back to file).
@@ -123,6 +151,10 @@ export function validateConfigObjectRaw(
if (avatarIssues.length > 0) {
return { ok: false, issues: avatarIssues };
}
const gatewayTailscaleBindIssues = validateGatewayTailscaleBind(validated.data as OpenClawConfig);
if (gatewayTailscaleBindIssues.length > 0) {
return { ok: false, issues: gatewayTailscaleBindIssues };
}
return {
ok: true,
config: validated.data as OpenClawConfig,

View File

@@ -1,3 +1,5 @@
import { isIpv6Address, parseCanonicalIpAddress } from "../shared/net/ip.js";
export const TAILSCALE_EXPOSURE_OPTIONS = [
{ value: "off", label: "Off", hint: "No Tailscale exposure" },
{
@@ -25,3 +27,36 @@ export const TAILSCALE_DOCS_LINES = [
"https://docs.openclaw.ai/gateway/tailscale",
"https://docs.openclaw.ai/web",
] as const;
function normalizeTailnetHostForUrl(rawHost: string): string | null {
const trimmed = rawHost.trim().replace(/\.$/, "");
if (!trimmed) {
return null;
}
const parsed = parseCanonicalIpAddress(trimmed);
if (parsed && isIpv6Address(parsed)) {
return `[${parsed.toString().toLowerCase()}]`;
}
return trimmed;
}
export function buildTailnetHttpsOrigin(rawHost: string): string | null {
const normalizedHost = normalizeTailnetHostForUrl(rawHost);
if (!normalizedHost) {
return null;
}
try {
return new URL(`https://${normalizedHost}`).origin;
} catch {
return null;
}
}
export function appendAllowedOrigin(existing: string[] | undefined, origin: string): string[] {
const current = existing ?? [];
const normalized = origin.toLowerCase();
if (current.some((entry) => entry.toLowerCase() === normalized)) {
return current;
}
return [...current, origin];
}

View File

@@ -172,7 +172,6 @@ async function authorizeCanvasRequest(params: {
}
async function enforcePluginRouteGatewayAuth(params: {
requestPath: string;
req: IncomingMessage;
res: ServerResponse;
auth: ResolvedGatewayAuth;
@@ -180,9 +179,6 @@ async function enforcePluginRouteGatewayAuth(params: {
allowRealIpFallback: boolean;
rateLimiter?: AuthRateLimiter;
}): Promise<boolean> {
if (!isProtectedPluginRoutePath(params.requestPath)) {
return true;
}
const token = getBearerToken(params.req);
const authResult = await authorizeHttpGatewayConnect({
auth: params.auth,
@@ -460,6 +456,7 @@ export function createGatewayHttpServer(opts: {
strictTransportSecurityHeader?: string;
handleHooksRequest: HooksRequestHandler;
handlePluginRequest?: HooksRequestHandler;
shouldEnforcePluginGatewayAuth?: (requestPath: string) => boolean;
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
@@ -477,6 +474,7 @@ export function createGatewayHttpServer(opts: {
strictTransportSecurityHeader,
handleHooksRequest,
handlePluginRequest,
shouldEnforcePluginGatewayAuth,
resolvedAuth,
rateLimiter,
} = opts;
@@ -527,26 +525,6 @@ export function createGatewayHttpServer(opts: {
if (await handleSlackHttpRequest(req, res)) {
return;
}
if (handlePluginRequest) {
// Protected plugin route prefixes are gateway-auth protected by default.
// Non-protected plugin routes remain plugin-owned and must enforce
// their own auth when exposing sensitive functionality.
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
requestPath,
req,
res,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
});
if (!pluginAuthOk) {
return;
}
if (await handlePluginRequest(req, res)) {
return;
}
}
if (openResponsesEnabled) {
if (
await handleOpenResponsesHttpRequest(req, res, {
@@ -615,6 +593,25 @@ export function createGatewayHttpServer(opts: {
return;
}
}
// Plugins run last so built-in gateway routes keep precedence on overlapping paths.
if (handlePluginRequest) {
if ((shouldEnforcePluginGatewayAuth ?? isProtectedPluginRoutePath)(requestPath)) {
const pluginAuthOk = await enforcePluginRouteGatewayAuth({
req,
res,
auth: resolvedAuth,
trustedProxies,
allowRealIpFallback,
rateLimiter,
});
if (!pluginAuthOk) {
return;
}
}
if (await handlePluginRequest(req, res)) {
return;
}
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");

View File

@@ -12,6 +12,7 @@ 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,
@@ -27,7 +28,10 @@ import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-h
import type { DedupeEntry } from "./server-shared.js";
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
import { listenGatewayHttpServer } from "./server/http-listen.js";
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
import {
createGatewayPluginRequestHandler,
isRegisteredPluginHttpRoutePath,
} from "./server/plugins-http.js";
import type { GatewayTlsRuntime } from "./server/tls.js";
import type { GatewayWsClient } from "./server/ws-types.js";
@@ -115,6 +119,12 @@ export async function createGatewayRuntimeState(params: {
registry: params.pluginRegistry,
log: params.logPlugins,
});
const shouldEnforcePluginGatewayAuth = (requestPath: string): boolean => {
if (isProtectedPluginRoutePath(requestPath)) {
return true;
}
return isRegisteredPluginHttpRoutePath(params.pluginRegistry, requestPath);
};
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
if (!isLoopbackHost(params.bindHost)) {
@@ -138,6 +148,7 @@ export async function createGatewayRuntimeState(params: {
strictTransportSecurityHeader: params.strictTransportSecurityHeader,
handleHooksRequest,
handlePluginRequest,
shouldEnforcePluginGatewayAuth,
resolvedAuth: params.resolvedAuth,
rateLimiter: params.rateLimiter,
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,

View File

@@ -54,6 +54,25 @@ describe("gateway config methods", () => {
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("raw must be an object");
});
it("rejects config.patch when tailscale serve/funnel is paired with non-loopback bind", async () => {
const res = await rpcReq<{
ok?: boolean;
error?: { details?: { issues?: Array<{ path?: string }> } };
}>(requireWs(), "config.patch", {
raw: JSON.stringify({
gateway: {
bind: "lan",
tailscale: { mode: "serve" },
},
}),
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("invalid config");
const issues = (res.error as { details?: { issues?: Array<{ path?: string }> } } | undefined)
?.details?.issues;
expect(issues?.some((issue) => issue.path === "gateway.bind")).toBe(true);
});
});
describe("gateway server sessions", () => {

View File

@@ -18,6 +18,7 @@ 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";
@@ -377,6 +378,55 @@ export async function startGatewayServer(
setPreRestartDeferralCheck(
() => 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.`,
);
}
}
initSubagentRegistry();
const defaultAgentId = resolveDefaultAgentId(cfgAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(cfgAtStart, defaultAgentId);

View File

@@ -3,7 +3,7 @@ import { describe, expect, test, vi } from "vitest";
import type { createSubsystemLogger } from "../logging/subsystem.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import type { HooksConfigResolved } from "./hooks.js";
import { canonicalizePathVariant } from "./security-path.js";
import { canonicalizePathVariant, isProtectedPluginRoutePath } from "./security-path.js";
import { createGatewayHttpServer, createHooksRequestHandler } from "./server-http.js";
import { withTempConfig } from "./test-temp-config.js";
@@ -243,7 +243,7 @@ describe("gateway plugin HTTP auth boundary", () => {
});
});
test("requires gateway auth for /api/channels/* plugin routes and allows authenticated pass-through", async () => {
test("requires gateway auth for protected plugin route space and allows authenticated pass-through", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
@@ -287,6 +287,8 @@ describe("gateway plugin HTTP auth boundary", () => {
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
shouldEnforcePluginGatewayAuth: (requestPath) =>
isProtectedPluginRoutePath(requestPath) || requestPath === "/plugin/public",
resolvedAuth,
});
@@ -328,10 +330,168 @@ describe("gateway plugin HTTP auth boundary", () => {
createRequest({ path: "/plugin/public" }),
unauthenticatedPublic.res,
);
expect(unauthenticatedPublic.res.statusCode).toBe(200);
expect(unauthenticatedPublic.getBody()).toContain('"route":"public"');
expect(unauthenticatedPublic.res.statusCode).toBe(401);
expect(unauthenticatedPublic.getBody()).toContain("Unauthorized");
expect(handlePluginRequest).toHaveBeenCalledTimes(2);
expect(handlePluginRequest).toHaveBeenCalledTimes(1);
},
});
});
test("keeps wildcard plugin handlers ungated when auth enforcement predicate excludes their paths", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: { gateway: { trustedProxies: [] } },
prefix: "openclaw-plugin-http-auth-wildcard-handler-test-",
run: async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname === "/plugin/routed") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "routed" }));
return true;
}
if (pathname === "/googlechat") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "wildcard-handler" }));
return true;
}
return false;
});
const server = createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: false,
controlUiBasePath: "/__control__",
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
shouldEnforcePluginGatewayAuth: (requestPath) =>
requestPath.startsWith("/api/channels") || requestPath === "/plugin/routed",
resolvedAuth,
});
const unauthenticatedRouted = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/plugin/routed" }),
unauthenticatedRouted.res,
);
expect(unauthenticatedRouted.res.statusCode).toBe(401);
expect(unauthenticatedRouted.getBody()).toContain("Unauthorized");
const unauthenticatedWildcard = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/googlechat" }),
unauthenticatedWildcard.res,
);
expect(unauthenticatedWildcard.res.statusCode).toBe(200);
expect(unauthenticatedWildcard.getBody()).toContain('"route":"wildcard-handler"');
const authenticatedRouted = createResponse();
await dispatchRequest(
server,
createRequest({
path: "/plugin/routed",
authorization: "Bearer test-token",
}),
authenticatedRouted.res,
);
expect(authenticatedRouted.res.statusCode).toBe(200);
expect(authenticatedRouted.getBody()).toContain('"route":"routed"');
},
});
});
test("uses /api/channels auth by default while keeping wildcard handlers ungated with no predicate", async () => {
const resolvedAuth: ResolvedGatewayAuth = {
mode: "token",
token: "test-token",
password: undefined,
allowTailscale: false,
};
await withTempConfig({
cfg: { gateway: { trustedProxies: [] } },
prefix: "openclaw-plugin-http-auth-wildcard-default-test-",
run: async () => {
const handlePluginRequest = vi.fn(async (req: IncomingMessage, res: ServerResponse) => {
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
if (pathname === "/api/channels/nostr/default/profile") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "channel-default" }));
return true;
}
if (pathname === "/googlechat") {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify({ ok: true, route: "wildcard-default" }));
return true;
}
return false;
});
const server = createGatewayHttpServer({
canvasHost: null,
clients: new Set(),
controlUiEnabled: false,
controlUiBasePath: "/__control__",
openAiChatCompletionsEnabled: false,
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
resolvedAuth,
});
const unauthenticated = createResponse();
await dispatchRequest(server, createRequest({ path: "/googlechat" }), unauthenticated.res);
expect(unauthenticated.res.statusCode).toBe(200);
expect(unauthenticated.getBody()).toContain('"route":"wildcard-default"');
const unauthenticatedChannel = createResponse();
await dispatchRequest(
server,
createRequest({ path: "/api/channels/nostr/default/profile" }),
unauthenticatedChannel.res,
);
expect(unauthenticatedChannel.res.statusCode).toBe(401);
expect(unauthenticatedChannel.getBody()).toContain("Unauthorized");
const authenticated = createResponse();
await dispatchRequest(
server,
createRequest({
path: "/googlechat",
authorization: "Bearer test-token",
}),
authenticated.res,
);
expect(authenticated.res.statusCode).toBe(200);
expect(authenticated.getBody()).toContain('"route":"wildcard-default"');
const authenticatedChannel = createResponse();
await dispatchRequest(
server,
createRequest({
path: "/api/channels/nostr/default/profile",
authorization: "Bearer test-token",
}),
authenticatedChannel.res,
);
expect(authenticatedChannel.res.statusCode).toBe(200);
expect(authenticatedChannel.getBody()).toContain('"route":"channel-default"');
},
});
});
@@ -369,6 +529,7 @@ describe("gateway plugin HTTP auth boundary", () => {
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath,
resolvedAuth,
});
@@ -418,6 +579,7 @@ describe("gateway plugin HTTP auth boundary", () => {
openResponsesEnabled: false,
handleHooksRequest: async () => false,
handlePluginRequest,
shouldEnforcePluginGatewayAuth: isProtectedPluginRoutePath,
resolvedAuth,
});

View File

@@ -2,7 +2,10 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { describe, expect, it, vi } from "vitest";
import { makeMockHttpResponse } from "../test-http-response.js";
import { createTestRegistry } from "./__tests__/test-utils.js";
import { createGatewayPluginRequestHandler } from "./plugins-http.js";
import {
createGatewayPluginRequestHandler,
isRegisteredPluginHttpRoutePath,
} from "./plugins-http.js";
describe("createGatewayPluginRequestHandler", () => {
it("returns false when no handlers are registered", async () => {
@@ -97,3 +100,36 @@ describe("createGatewayPluginRequestHandler", () => {
expect(end).toHaveBeenCalledWith("Internal Server Error");
});
});
describe("plugin HTTP registry helpers", () => {
it("detects registered route paths", () => {
const registry = createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/demo",
handler: () => {},
source: "route",
},
],
});
expect(isRegisteredPluginHttpRoutePath(registry, "/demo")).toBe(true);
expect(isRegisteredPluginHttpRoutePath(registry, "/missing")).toBe(false);
});
it("matches canonicalized variants of registered route paths", () => {
const registry = createTestRegistry({
httpRoutes: [
{
pluginId: "route",
path: "/api/demo",
handler: () => {},
source: "route",
},
],
});
expect(isRegisteredPluginHttpRoutePath(registry, "/api//demo")).toBe(true);
expect(isRegisteredPluginHttpRoutePath(registry, "/API/demo")).toBe(true);
expect(isRegisteredPluginHttpRoutePath(registry, "/api/%2564emo")).toBe(true);
});
});

View File

@@ -1,6 +1,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";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
@@ -9,6 +10,18 @@ export type PluginHttpRequestHandler = (
res: ServerResponse,
) => Promise<boolean>;
// 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.
export function isRegisteredPluginHttpRoutePath(
registry: PluginRegistry,
pathname: string,
): boolean {
const canonicalPath = canonicalizePathVariant(pathname);
const routes = registry.httpRoutes ?? [];
return routes.some((entry) => canonicalizePathVariant(entry.path) === canonicalPath);
}
export function createGatewayPluginRequestHandler(params: {
registry: PluginRegistry;
log: SubsystemLogger;

View File

@@ -5,6 +5,7 @@ import type { WizardPrompter, WizardSelectParams } from "./prompts.js";
const mocks = vi.hoisted(() => ({
randomToken: vi.fn(),
getTailnetHostname: vi.fn(),
}));
vi.mock("../commands/onboard-helpers.js", async (importActual) => {
@@ -17,6 +18,7 @@ vi.mock("../commands/onboard-helpers.js", async (importActual) => {
vi.mock("../infra/tailscale.js", () => ({
findTailscaleBinary: vi.fn(async () => undefined),
getTailnetHostname: mocks.getTailnetHostname,
}));
import { configureGatewayForOnboarding } from "./onboarding.gateway-config.js";
@@ -136,4 +138,110 @@ describe("configureGatewayForOnboarding", () => {
"http://127.0.0.1:18789",
]);
});
it("adds Tailscale origin to controlUi.allowedOrigins when tailscale serve is enabled", async () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const prompter = createPrompter({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
});
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
"https://my-host.tail1234.ts.net",
);
});
it("does not add Tailscale origin when getTailnetHostname fails", async () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.getTailnetHostname.mockRejectedValue(new Error("not found"));
const prompter = createPrompter({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
});
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toBeUndefined();
});
it("formats IPv6 Tailscale fallback addresses as valid HTTPS origins", async () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.getTailnetHostname.mockResolvedValue("fd7a:115c:a1e0::99");
const prompter = createPrompter({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
});
expect(result.nextConfig.gateway?.controlUi?.allowedOrigins).toContain(
"https://[fd7a:115c:a1e0::99]",
);
});
it("does not duplicate Tailscale origin when allowlist already contains case variants", async () => {
mocks.randomToken.mockReturnValue("generated-token");
mocks.getTailnetHostname.mockResolvedValue("my-host.tail1234.ts.net");
const prompter = createPrompter({
selectQueue: ["loopback", "token", "serve"],
textQueue: ["18789", undefined],
});
const runtime = createRuntime();
const result = await configureGatewayForOnboarding({
flow: "advanced",
baseConfig: {},
nextConfig: {
gateway: {
controlUi: {
allowedOrigins: ["HTTPS://MY-HOST.TAIL1234.TS.NET"],
},
},
},
localPort: 18789,
quickstartGateway: createQuickstartGateway("token"),
prompter,
runtime,
});
const origins = result.nextConfig.gateway?.controlUi?.allowedOrigins ?? [];
const tsOriginCount = origins.filter(
(origin) => origin.toLowerCase() === "https://my-host.tail1234.ts.net",
).length;
expect(tsOriginCount).toBe(1);
});
});

View File

@@ -6,11 +6,13 @@ import {
import type { GatewayAuthChoice } from "../commands/onboard-types.js";
import type { GatewayBindMode, GatewayTailscaleMode, OpenClawConfig } from "../config/config.js";
import {
appendAllowedOrigin,
buildTailnetHttpsOrigin,
TAILSCALE_DOCS_LINES,
TAILSCALE_EXPOSURE_OPTIONS,
TAILSCALE_MISSING_BIN_NOTE_LINES,
} from "../gateway/gateway-config-prompts.shared.js";
import { findTailscaleBinary } from "../infra/tailscale.js";
import { findTailscaleBinary, getTailnetHostname } from "../infra/tailscale.js";
import type { RuntimeEnv } from "../runtime.js";
import { validateIPv4AddressInput } from "../shared/net/ipv4.js";
import type {
@@ -137,8 +139,10 @@ export async function configureGatewayForOnboarding(
});
// Detect Tailscale binary before proceeding with serve/funnel setup.
// Persist the path so getTailnetHostname can reuse it for origin injection.
let tailscaleBin: string | null = null;
if (tailscaleMode !== "off") {
const tailscaleBin = await findTailscaleBinary();
tailscaleBin = await findTailscaleBinary();
if (!tailscaleBin) {
await prompter.note(TAILSCALE_MISSING_BIN_NOTE_LINES.join("\n"), "Tailscale Warning");
}
@@ -253,6 +257,28 @@ export async function configureGatewayForOnboarding(
};
}
// 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,
},
},
};
}
}
// 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
// /phone arm ... (phone-control plugin).