mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 12:14:46 +00:00
fix(gateway): require auth for exposed startup
Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user