fix(gateway): require auth for exposed startup

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-05-17 05:24:24 +01:00
parent 18812bfc03
commit abb06c6e40
4 changed files with 113 additions and 8 deletions

View File

@@ -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.

View File

@@ -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"]

View File

@@ -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<string, string | undefined>;
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<typeof import("../../gateway/net.js")>();
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 });

View File

@@ -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<void> {
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({