From abb06c6e4047e3bdfd193ddfe283624ab0135cc4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 17 May 2026 05:24:24 +0100 Subject: [PATCH] fix(gateway): require auth for exposed startup Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com> --- CHANGELOG.md | 1 + Dockerfile | 2 +- .../gateway-cli/run.option-collisions.test.ts | 93 ++++++++++++++++++- src/cli/gateway-cli/run.ts | 25 +++-- 4 files changed, 113 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c43e4fd47fb..038578275ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/Docker: fail closed for non-loopback gateway starts without explicit shared-secret or trusted-proxy auth, and stop the image default command from bypassing config validation. Fixes #82865. (#82866) Thanks @coygeek. - CLI/sessions: let `openclaw sessions cleanup --fix-missing` prune malformed rows with unresolvable transcript metadata instead of throwing. Fixes #80970. (#82745) Thanks @IWhatsskill. - Gateway/usage: refresh large session usage summaries in the background and reuse durable transcript metadata so `sessions.usage` no longer blocks Gateway requests on full transcript rescans. Fixes #82773. (#82778) Thanks @hclsys. - TUI: restore the submitted draft when chat is busy instead of clearing it or queueing another run. Fixes #45326. (#82774) Thanks @hyspacex. diff --git a/Dockerfile b/Dockerfile index 8884ff8027a..9ba3b1a978f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -293,4 +293,4 @@ USER node HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" ENTRYPOINT ["tini", "-s", "--"] -CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"] +CMD ["node", "openclaw.mjs", "gateway"] diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index 661572e9c39..b185b17663d 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -40,9 +40,17 @@ const writeDiagnosticStabilityBundleForFailureSync = vi.fn((_reason: string, _er const controlUiState = vi.hoisted(() => ({ root: "/tmp/openclaw-control-ui" as string | null, })); +const netState = vi.hoisted(() => ({ + autoBindHost: "127.0.0.1", + container: false, +})); const withoutSupervisorEnv = Object.fromEntries( SUPERVISOR_HINT_ENV_VARS.map((key) => [key, undefined]), ) as Record; +const withoutGatewayAuthEnv = { + OPENCLAW_GATEWAY_TOKEN: undefined, + OPENCLAW_GATEWAY_PASSWORD: undefined, +}; const { runtimeErrors, defaultRuntime, resetRuntimeCapture } = createCliRuntimeCapture(); @@ -85,6 +93,35 @@ vi.mock("../../gateway/auth.js", () => ({ }, })); +vi.mock("../../gateway/net.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + defaultGatewayBindMode: (tailscaleMode?: string) => { + if (tailscaleMode && tailscaleMode !== "off") { + return "loopback"; + } + return netState.container ? "auto" : "loopback"; + }, + isContainerEnvironment: () => netState.container, + resolveGatewayBindHost: async (bind?: string, customHost?: string) => { + if (bind === "auto") { + return netState.autoBindHost; + } + if (bind === "lan") { + return "0.0.0.0"; + } + if (bind === "custom") { + return customHost?.trim() || "0.0.0.0"; + } + if (bind === "tailnet") { + return "100.64.0.1"; + } + return "127.0.0.1"; + }, + }; +}); + vi.mock("../../gateway/server.js", () => ({ startGatewayServer: (port: number, opts?: unknown) => startGatewayServer(port, opts), })); @@ -177,6 +214,8 @@ describe("gateway run option collisions", () => { resetRuntimeCapture(); configState.cfg = {}; configState.snapshot = { exists: false }; + netState.autoBindHost = "127.0.0.1"; + netState.container = false; readBestEffortConfig.mockClear(); readConfigFileSnapshotWithPluginMetadata.mockClear(); controlUiState.root = "/tmp/openclaw-control-ui"; @@ -295,7 +334,9 @@ describe("gateway run option collisions", () => { }); it("starts gateway when token mode has no configured token (startup bootstrap path)", async () => { - await runGatewayCli(["gateway", "run", "--allow-unconfigured"]); + await withEnvAsync(withoutGatewayAuthEnv, async () => { + await runGatewayCli(["gateway", "run", "--allow-unconfigured"]); + }); expect(readConfigFileSnapshotWithPluginMetadata).toHaveBeenCalledTimes(1); expect(readBestEffortConfig).not.toHaveBeenCalled(); @@ -304,6 +345,56 @@ describe("gateway run option collisions", () => { expect(options.startupConfigSnapshotRead).toEqual({ snapshot: configState.snapshot }); }); + it("allows authless auto startup when it resolves to loopback", async () => { + await withEnvAsync(withoutGatewayAuthEnv, async () => { + await runGatewayCli(["gateway", "run", "--bind", "auto", "--allow-unconfigured"]); + }); + + const options = gatewayStartOptions(); + expect(options.bind).toBe("auto"); + }); + + it("blocks container auto startup without explicit gateway auth", async () => { + netState.autoBindHost = "0.0.0.0"; + netState.container = true; + + await withEnvAsync(withoutGatewayAuthEnv, async () => { + await expect(runGatewayCli(["gateway", "run", "--allow-unconfigured"])).rejects.toThrow( + "__exit__:78", + ); + }); + + expect(runtimeErrors.join("\n")).toContain("Refusing to bind gateway to auto without auth."); + expect(startGatewayServer).not.toHaveBeenCalled(); + }); + + it("blocks non-loopback startup without explicit gateway auth", async () => { + await withEnvAsync(withoutGatewayAuthEnv, async () => { + await expect( + runGatewayCli(["gateway", "run", "--bind", "lan", "--allow-unconfigured"]), + ).rejects.toThrow("__exit__:78"); + }); + + expect(runtimeErrors.join("\n")).toContain("Refusing to bind gateway to lan without auth."); + expect(startGatewayServer).not.toHaveBeenCalled(); + }); + + it("allows non-loopback startup when token auth is explicit", async () => { + await runGatewayCli([ + "gateway", + "run", + "--bind", + "lan", + "--token", + "tok_run", + "--allow-unconfigured", + ]); + + const options = gatewayStartOptions(); + expect(options.bind).toBe("lan"); + expect(options.auth?.token).toBe("tok_run"); + }); + it("uses the startup snapshot only for the first in-process gateway start", async () => { runGatewayLoop.mockImplementationOnce(async ({ start }: { start: GatewayLoopStart }) => { await start({ startupStartedAt: 1000 }); diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 7bd99d313b5..f6b7a07b4d2 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -15,6 +15,7 @@ import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import { defaultGatewayBindMode, isContainerEnvironment, + isLoopbackHost, resolveGatewayBindHost, } from "../../gateway/net.js"; import type { GatewayWsLogStyle } from "../../gateway/ws-logging.js"; @@ -217,6 +218,18 @@ function formatModeErrorList(modes: readonly string[]): string { return `${quoted.slice(0, -1).join(", ")}, or ${quoted[quoted.length - 1]}`; } +function shouldBlockGatewayBindWithoutExplicitAuth(params: { + bindHost: string; + hasSharedSecret: boolean; + resolvedAuthMode: GatewayAuthMode; +}): boolean { + return ( + !isLoopbackHost(params.bindHost) && + !params.hasSharedSecret && + params.resolvedAuthMode !== "trusted-proxy" + ); +} + async function maybeLogPendingControlUiBuild(cfg: OpenClawConfig): Promise { if (cfg.gateway?.controlUi?.enabled === false) { return; @@ -723,7 +736,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) { const hasSharedSecret = (resolvedAuthMode === "token" && tokenConfigured) || (resolvedAuthMode === "password" && passwordConfigured); - const canBootstrapToken = resolvedAuthMode === "token" && !tokenConfigured; const authHints: string[] = []; if (miskeys.hasGatewayToken) { authHints.push('Found "gateway.token" in config. Use "gateway.auth.token" instead.'); @@ -751,11 +763,13 @@ async function runGatewayCommand(opts: GatewayRunOpts) { "Gateway auth mode=none explicitly configured; all gateway connections are unauthenticated.", ); } + const healthHost = await resolveGatewayBindHost(bind, cfg.gateway?.customBindHost); if ( - bind !== "loopback" && - !hasSharedSecret && - !canBootstrapToken && - resolvedAuthMode !== "trusted-proxy" + shouldBlockGatewayBindWithoutExplicitAuth({ + bindHost: healthHost, + hasSharedSecret, + resolvedAuthMode, + }) ) { defaultRuntime.error( [ @@ -786,7 +800,6 @@ async function runGatewayCommand(opts: GatewayRunOpts) { gatewayLog.info("starting..."); startupTrace.mark("cli.gateway-loop"); - const healthHost = await resolveGatewayBindHost(bind, cfg.gateway?.customBindHost); let startupConfigSnapshotReadForNextStart = startupConfigSnapshotRead; const startLoop = async () => await runGatewayLoop({