Security: default gateway auth bootstrap and explicit mode none (#20686)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: be1b73182c
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-02-19 02:35:50 -05:00
committed by GitHub
parent a2e846f649
commit c5698caca3
18 changed files with 678 additions and 77 deletions

View File

@@ -34,6 +34,7 @@ describe("gateway auth", () => {
}),
).toMatchObject({
mode: "password",
modeSource: "password",
token: "env-token",
password: "env-password",
});
@@ -49,12 +50,42 @@ describe("gateway auth", () => {
} as NodeJS.ProcessEnv,
}),
).toMatchObject({
mode: "none",
mode: "token",
modeSource: "default",
token: undefined,
password: undefined,
});
});
it("resolves explicit auth mode none from config", () => {
expect(
resolveGatewayAuth({
authConfig: { mode: "none" },
env: {} as NodeJS.ProcessEnv,
}),
).toMatchObject({
mode: "none",
modeSource: "config",
token: undefined,
password: undefined,
});
});
it("marks mode source as override when runtime mode override is provided", () => {
expect(
resolveGatewayAuth({
authConfig: { mode: "password", password: "config-password" },
authOverride: { mode: "token" },
env: {} as NodeJS.ProcessEnv,
}),
).toMatchObject({
mode: "token",
modeSource: "override",
token: undefined,
password: "config-password",
});
});
it("does not throw when req is missing socket", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
@@ -90,6 +121,34 @@ describe("gateway auth", () => {
expect(res.reason).toBe("token_missing_config");
});
it("allows explicit auth mode none", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "none", allowTailscale: false },
connectAuth: null,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
it("keeps none mode authoritative even when token is present", async () => {
const auth = resolveGatewayAuth({
authConfig: { mode: "none", token: "configured-token" },
env: {} as NodeJS.ProcessEnv,
});
expect(auth).toMatchObject({
mode: "none",
modeSource: "config",
token: "configured-token",
});
const res = await authorizeGatewayConnect({
auth,
connectAuth: null,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("none");
});
it("reports missing and mismatched password reasons", async () => {
const missing = await authorizeGatewayConnect({
auth: { mode: "password", password: "secret", allowTailscale: false },

View File

@@ -20,9 +20,16 @@ import {
} from "./net.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
export type ResolvedGatewayAuthModeSource =
| "override"
| "config"
| "password"
| "token"
| "default";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
modeSource?: ResolvedGatewayAuthModeSource;
token?: string;
password?: string;
allowTailscale: boolean;
@@ -178,24 +185,55 @@ async function resolveVerifiedTailscaleUser(params: {
export function resolveGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null;
authOverride?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv;
tailscaleMode?: GatewayTailscaleMode;
}): ResolvedGatewayAuth {
const authConfig = params.authConfig ?? {};
const baseAuthConfig = params.authConfig ?? {};
const authOverride = params.authOverride ?? undefined;
const authConfig: GatewayAuthConfig = { ...baseAuthConfig };
if (authOverride) {
if (authOverride.mode !== undefined) {
authConfig.mode = authOverride.mode;
}
if (authOverride.token !== undefined) {
authConfig.token = authOverride.token;
}
if (authOverride.password !== undefined) {
authConfig.password = authOverride.password;
}
if (authOverride.allowTailscale !== undefined) {
authConfig.allowTailscale = authOverride.allowTailscale;
}
if (authOverride.rateLimit !== undefined) {
authConfig.rateLimit = authOverride.rateLimit;
}
if (authOverride.trustedProxy !== undefined) {
authConfig.trustedProxy = authOverride.trustedProxy;
}
}
const env = params.env ?? process.env;
const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined;
const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined;
const trustedProxy = authConfig.trustedProxy;
let mode: ResolvedGatewayAuth["mode"];
if (authConfig.mode) {
let modeSource: ResolvedGatewayAuth["modeSource"];
if (authOverride?.mode !== undefined) {
mode = authOverride.mode;
modeSource = "override";
} else if (authConfig.mode) {
mode = authConfig.mode;
modeSource = "config";
} else if (password) {
mode = "password";
modeSource = "password";
} else if (token) {
mode = "token";
modeSource = "token";
} else {
mode = "none";
mode = "token";
modeSource = "default";
}
const allowTailscale =
@@ -204,6 +242,7 @@ export function resolveGatewayAuth(params: {
return {
mode,
modeSource,
token,
password,
allowTailscale,
@@ -317,6 +356,10 @@ export async function authorizeGatewayConnect(params: {
return { ok: false, reason: result.reason };
}
if (auth.mode === "none") {
return { ok: true, method: "none" };
}
const limiter = params.rateLimiter;
const ip =
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;

View File

@@ -115,5 +115,42 @@ describe("resolveGatewayRuntimeConfig", () => {
expect(result.authMode).toBe("token");
expect(result.bindHost).toBe("0.0.0.0");
});
it("should allow loopback binding with explicit none mode", async () => {
const cfg = {
gateway: {
bind: "loopback" as const,
auth: {
mode: "none" as const,
},
},
};
const result = await resolveGatewayRuntimeConfig({
cfg,
port: 18789,
});
expect(result.authMode).toBe("none");
expect(result.bindHost).toBe("127.0.0.1");
});
it("should reject lan binding with explicit none mode", async () => {
const cfg = {
gateway: {
bind: "lan" as const,
auth: {
mode: "none" as const,
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
}),
).rejects.toThrow("refusing to bind gateway");
});
});
});

View File

@@ -12,6 +12,7 @@ import {
import { normalizeControlUiBasePath } from "./control-ui-shared.js";
import { resolveHooksConfig } from "./hooks.js";
import { isLoopbackHost, resolveGatewayBindHost } from "./net.js";
import { mergeGatewayTailscaleConfig } from "./startup-auth.js";
export type GatewayRuntimeConfig = {
bindHost: string;
@@ -57,21 +58,13 @@ export async function resolveGatewayRuntimeConfig(params: {
typeof controlUiRootRaw === "string" && controlUiRootRaw.trim().length > 0
? controlUiRootRaw.trim()
: undefined;
const authBase = params.cfg.gateway?.auth ?? {};
const authOverrides = params.auth ?? {};
const authConfig = {
...authBase,
...authOverrides,
};
const tailscaleBase = params.cfg.gateway?.tailscale ?? {};
const tailscaleOverrides = params.tailscale ?? {};
const tailscaleConfig = {
...tailscaleBase,
...tailscaleOverrides,
};
const tailscaleConfig = mergeGatewayTailscaleConfig(tailscaleBase, tailscaleOverrides);
const tailscaleMode = tailscaleConfig.mode ?? "off";
const resolvedAuth = resolveGatewayAuth({
authConfig,
authConfig: params.cfg.gateway?.auth,
authOverride: params.auth,
env: process.env,
tailscaleMode,
});

View File

@@ -616,6 +616,36 @@ describe("gateway server auth/connect", () => {
});
});
describe("explicit none auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;
let prevToken: string | undefined;
beforeAll(async () => {
prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
testState.gatewayAuth = { mode: "none" };
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (prevToken === undefined) {
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
}
});
test("allows loopback connect without shared secret when mode is none", async () => {
const ws = await openWs(port);
const res = await connectReq(ws, { skipDefaultAuth: true });
expect(res.ok).toBe(true);
ws.close();
});
});
describe("tailscale auth", () => {
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port: number;

View File

@@ -84,6 +84,7 @@ import {
refreshGatewayHealthSnapshot,
} from "./server/health-state.js";
import { loadGatewayTlsRuntime } from "./server/tls.js";
import { ensureGatewayStartupAuth } from "./startup-auth.js";
export { __resetModelCatalogCacheForTest } from "./server-model-catalog.js";
@@ -227,7 +228,26 @@ export async function startGatewayServer(
}
}
const cfgAtStart = loadConfig();
let cfgAtStart = loadConfig();
const authBootstrap = await ensureGatewayStartupAuth({
cfg: cfgAtStart,
env: process.env,
authOverride: opts.auth,
tailscaleOverride: opts.tailscale,
persist: true,
});
cfgAtStart = authBootstrap.cfg;
if (authBootstrap.generatedToken) {
if (authBootstrap.persistedGeneratedToken) {
log.info(
"Gateway auth token was missing. Generated a new token and saved it to config (gateway.auth.token).",
);
} else {
log.warn(
"Gateway auth token was missing. Generated a runtime token for this startup without changing config; restart will generate a different token. Persist one with `openclaw config set gateway.auth.mode token` and `openclaw config set gateway.auth.token <token>`.",
);
}
}
const diagnosticsEnabled = isDiagnosticsEnabled(cfgAtStart);
if (diagnosticsEnabled) {
startDiagnosticHeartbeat();

View File

@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
const mocks = vi.hoisted(() => ({
writeConfigFile: vi.fn(async (_cfg: OpenClawConfig) => {}),
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
writeConfigFile: mocks.writeConfigFile,
};
});
import { ensureGatewayStartupAuth } from "./startup-auth.js";
describe("ensureGatewayStartupAuth", () => {
beforeEach(() => {
vi.restoreAllMocks();
mocks.writeConfigFile.mockReset();
});
it("generates and persists a token when startup auth is missing", async () => {
const result = await ensureGatewayStartupAuth({
cfg: {},
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(true);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).toHaveBeenCalledTimes(1);
const persisted = mocks.writeConfigFile.mock.calls[0]?.[0];
expect(persisted?.gateway?.auth?.mode).toBe("token");
expect(persisted?.gateway?.auth?.token).toBe(result.generatedToken);
});
it("does not generate when token already exists", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "configured-token",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe("configured-token");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("does not generate in password mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("password");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("does not generate in trusted-proxy mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: { userHeader: "x-forwarded-user" },
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("trusted-proxy");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("does not generate in explicit none mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("none");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("treats undefined token override as no override", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "from-config",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
authOverride: { mode: "token", token: undefined },
persist: true,
});
expect(result.generatedToken).toBeUndefined();
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe("from-config");
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("keeps generated token ephemeral when runtime override flips explicit non-token mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
authOverride: { mode: "token" },
persist: true,
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("keeps generated token ephemeral when runtime override flips explicit none mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "none",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
authOverride: { mode: "token" },
persist: true,
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
it("keeps generated token ephemeral when runtime override flips implicit password mode", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
password: "configured-password",
},
},
};
const result = await ensureGatewayStartupAuth({
cfg,
env: {} as NodeJS.ProcessEnv,
authOverride: { mode: "token" },
persist: true,
});
expect(result.generatedToken).toMatch(/^[0-9a-f]{48}$/);
expect(result.persistedGeneratedToken).toBe(false);
expect(result.auth.mode).toBe("token");
expect(result.auth.token).toBe(result.generatedToken);
expect(mocks.writeConfigFile).not.toHaveBeenCalled();
});
});

147
src/gateway/startup-auth.ts Normal file
View File

@@ -0,0 +1,147 @@
import crypto from "node:crypto";
import type {
GatewayAuthConfig,
GatewayTailscaleConfig,
OpenClawConfig,
} from "../config/config.js";
import { writeConfigFile } from "../config/config.js";
import { resolveGatewayAuth, type ResolvedGatewayAuth } from "./auth.js";
export function mergeGatewayAuthConfig(
base?: GatewayAuthConfig,
override?: GatewayAuthConfig,
): GatewayAuthConfig {
const merged: GatewayAuthConfig = { ...base };
if (!override) {
return merged;
}
if (override.mode !== undefined) {
merged.mode = override.mode;
}
if (override.token !== undefined) {
merged.token = override.token;
}
if (override.password !== undefined) {
merged.password = override.password;
}
if (override.allowTailscale !== undefined) {
merged.allowTailscale = override.allowTailscale;
}
if (override.rateLimit !== undefined) {
merged.rateLimit = override.rateLimit;
}
if (override.trustedProxy !== undefined) {
merged.trustedProxy = override.trustedProxy;
}
return merged;
}
export function mergeGatewayTailscaleConfig(
base?: GatewayTailscaleConfig,
override?: GatewayTailscaleConfig,
): GatewayTailscaleConfig {
const merged: GatewayTailscaleConfig = { ...base };
if (!override) {
return merged;
}
if (override.mode !== undefined) {
merged.mode = override.mode;
}
if (override.resetOnExit !== undefined) {
merged.resetOnExit = override.resetOnExit;
}
return merged;
}
function resolveGatewayAuthFromConfig(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
tailscaleOverride?: GatewayTailscaleConfig;
}) {
const tailscaleConfig = mergeGatewayTailscaleConfig(
params.cfg.gateway?.tailscale,
params.tailscaleOverride,
);
return resolveGatewayAuth({
authConfig: params.cfg.gateway?.auth,
authOverride: params.authOverride,
env: params.env,
tailscaleMode: tailscaleConfig.mode ?? "off",
});
}
function shouldPersistGeneratedToken(params: {
persistRequested: boolean;
resolvedAuth: ResolvedGatewayAuth;
}): boolean {
if (!params.persistRequested) {
return false;
}
// Keep CLI/runtime mode overrides ephemeral: startup should not silently
// mutate durable auth policy when mode was chosen by an override flag.
if (params.resolvedAuth.modeSource === "override") {
return false;
}
return true;
}
export async function ensureGatewayStartupAuth(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
authOverride?: GatewayAuthConfig;
tailscaleOverride?: GatewayTailscaleConfig;
persist?: boolean;
}): Promise<{
cfg: OpenClawConfig;
auth: ReturnType<typeof resolveGatewayAuth>;
generatedToken?: string;
persistedGeneratedToken: boolean;
}> {
const env = params.env ?? process.env;
const persistRequested = params.persist === true;
const resolved = resolveGatewayAuthFromConfig({
cfg: params.cfg,
env,
authOverride: params.authOverride,
tailscaleOverride: params.tailscaleOverride,
});
if (resolved.mode !== "token" || (resolved.token?.trim().length ?? 0) > 0) {
return { cfg: params.cfg, auth: resolved, persistedGeneratedToken: false };
}
const generatedToken = crypto.randomBytes(24).toString("hex");
const nextCfg: OpenClawConfig = {
...params.cfg,
gateway: {
...params.cfg.gateway,
auth: {
...params.cfg.gateway?.auth,
mode: "token",
token: generatedToken,
},
},
};
const persist = shouldPersistGeneratedToken({
persistRequested,
resolvedAuth: resolved,
});
if (persist) {
await writeConfigFile(nextCfg);
}
const nextAuth = resolveGatewayAuthFromConfig({
cfg: nextCfg,
env,
authOverride: params.authOverride,
tailscaleOverride: params.tailscaleOverride,
});
return {
cfg: nextCfg,
auth: nextAuth,
generatedToken,
persistedGeneratedToken: persist,
};
}