From f3b636481f2a17208fc0ac1671813e549649a5d7 Mon Sep 17 00:00:00 2001 From: Alex Navarro <78754189+navarrotech@users.noreply.github.com> Date: Sun, 12 Apr 2026 09:33:05 -0600 Subject: [PATCH] fix(gateway): reject known-weak example auth credentials at startup (#64586) --- .env.example | 11 ++- CHANGELOG.md | 1 + src/gateway/startup-auth.test.ts | 140 ++++++++++++++++++++++++++++++- src/gateway/startup-auth.ts | 51 +++++++++++ 4 files changed, 197 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 41df435b8f9..44c30da44cd 100644 --- a/.env.example +++ b/.env.example @@ -14,12 +14,15 @@ # ----------------------------------------------------------------------------- # Gateway auth + paths # ----------------------------------------------------------------------------- -# Recommended if the gateway binds beyond loopback. -OPENCLAW_GATEWAY_TOKEN=change-me-to-a-long-random-token -# Example generator: openssl rand -hex 32 +# Required if the gateway binds beyond loopback. Leave blank to have OpenClaw +# auto-generate a token on first start, or provide your own using +# `openssl rand -hex 32`. The gateway will refuse to start if this is set to +# the documented example placeholder, so never copy-paste an example value +# from docs or tutorials into this file verbatim. +OPENCLAW_GATEWAY_TOKEN= # Optional alternative auth mode (use token OR password). -# OPENCLAW_GATEWAY_PASSWORD=change-me-to-a-strong-password +# OPENCLAW_GATEWAY_PASSWORD= # Optional path overrides (defaults shown for reference). # OPENCLAW_STATE_DIR=~/.openclaw diff --git a/CHANGELOG.md b/CHANGELOG.md index 718c62ab496..4b520722392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/auth: blank the shipped example gateway credential in `.env.example` and fail startup when a copied placeholder token or password is still configured, so operators cannot accidentally launch with a publicly known secret. (#64586) Thanks @navarrotech and @vincentkoc. - Memory/active-memory+dreaming: keep active-memory recall runs on the strongest resolved channel, consume managed dreaming heartbeat events exactly once, stop dreaming from re-ingesting its own narrative transcripts, and add explicit repair/dedupe recovery flows in CLI, doctor, and the Dreams UI. - Matrix/mentions: keep room mention gating strict while accepting visible `@displayName` Matrix URI labels, so `requireMention` works for non-OpenClaw Matrix clients again. (#64796) Thanks @hclsys. - Doctor: warn when on-disk agent directories still exist under `~/.openclaw/agents//agent` but the matching `agents.list[]` entries are missing from config. (#65113) Thanks @neeravmakwana. diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 2527f94dde0..0cae79a21b8 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -14,13 +14,17 @@ vi.mock("../config/config.js", async () => { }; }); +let assertGatewayAuthNotKnownWeak: typeof import("./startup-auth.js").assertGatewayAuthNotKnownWeak; let assertHooksTokenSeparateFromGatewayAuth: typeof import("./startup-auth.js").assertHooksTokenSeparateFromGatewayAuth; let ensureGatewayStartupAuth: typeof import("./startup-auth.js").ensureGatewayStartupAuth; async function loadFreshStartupAuthModuleForTest() { vi.resetModules(); - ({ assertHooksTokenSeparateFromGatewayAuth, ensureGatewayStartupAuth } = - await import("./startup-auth.js")); + ({ + assertGatewayAuthNotKnownWeak, + assertHooksTokenSeparateFromGatewayAuth, + ensureGatewayStartupAuth, + } = await import("./startup-auth.js")); } describe("ensureGatewayStartupAuth", () => { @@ -408,6 +412,138 @@ describe("ensureGatewayStartupAuth", () => { }), ).rejects.toThrow(/hooks\.token must not match gateway auth token/i); }); + + it("rejects the .env.example placeholder token supplied via environment", async () => { + await expect( + ensureGatewayStartupAuth({ + cfg: {}, + env: { + OPENCLAW_GATEWAY_TOKEN: "change-me-to-a-long-random-token", + } as NodeJS.ProcessEnv, + }), + ).rejects.toThrow(/example placeholder/i); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("rejects the .env.example placeholder token supplied via config", async () => { + await expect( + ensureGatewayStartupAuth({ + cfg: { + gateway: { + auth: { + mode: "token", + token: "change-me-to-a-long-random-token", + }, + }, + }, + env: {} as NodeJS.ProcessEnv, + }), + ).rejects.toThrow(/example placeholder/i); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("rejects the .env.example placeholder password supplied via config", async () => { + await expect( + ensureGatewayStartupAuth({ + cfg: { + gateway: { + auth: { + mode: "password", + password: "change-me-to-a-strong-password", // pragma: allowlist secret + }, + }, + }, + env: {} as NodeJS.ProcessEnv, + }), + ).rejects.toThrow(/example placeholder/i); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + }); + + it("accepts any non-placeholder token (negative control)", async () => { + await expectResolvedToken({ + cfg: { + gateway: { + auth: { + mode: "token", + token: "a-legit-random-token-0123456789abcdef", + }, + }, + }, + env: {} as NodeJS.ProcessEnv, + expectedToken: "a-legit-random-token-0123456789abcdef", + }); + }); +}); + +describe("assertGatewayAuthNotKnownWeak", () => { + beforeEach(async () => { + await loadFreshStartupAuthModuleForTest(); + }); + + it("throws on the known-weak token sentinel", () => { + expect(() => + assertGatewayAuthNotKnownWeak({ + mode: "token", + modeSource: "config", + token: "change-me-to-a-long-random-token", + allowTailscale: false, + }), + ).toThrow(/example placeholder/i); + }); + + it("throws on the known-weak password sentinel", () => { + expect(() => + assertGatewayAuthNotKnownWeak({ + mode: "password", + modeSource: "config", + password: "change-me-to-a-strong-password", // pragma: allowlist secret + allowTailscale: false, + }), + ).toThrow(/example placeholder/i); + }); + + it("ignores whitespace-padded placeholder tokens (trimmed match)", () => { + expect(() => + assertGatewayAuthNotKnownWeak({ + mode: "token", + modeSource: "config", + token: " change-me-to-a-long-random-token ", + allowTailscale: false, + }), + ).toThrow(/example placeholder/i); + }); + + it("does not throw on an empty token (falls through to generation path)", () => { + expect(() => + assertGatewayAuthNotKnownWeak({ + mode: "token", + modeSource: "config", + token: "", + allowTailscale: false, + }), + ).not.toThrow(); + }); + + it("does not throw on a real token", () => { + expect(() => + assertGatewayAuthNotKnownWeak({ + mode: "token", + modeSource: "config", + token: "a-legit-random-token-0123456789abcdef", + allowTailscale: false, + }), + ).not.toThrow(); + }); + + it("does not throw on the none mode", () => { + expect(() => + assertGatewayAuthNotKnownWeak({ + mode: "none", + modeSource: "default", + allowTailscale: false, + }), + ).not.toThrow(); + }); }); describe("assertHooksTokenSeparateFromGatewayAuth", () => { diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index d7521b68bba..1cc94070a8c 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -19,6 +19,26 @@ import { trimToUndefined, } from "./credentials.js"; +/** + * Placeholder credentials that have ever shipped in `.env.example` or been + * used as copy-paste examples in onboarding docs. If any of these ever + * becomes the resolved gateway credential at startup, reject the launch — + * the operator almost certainly copied an example file verbatim without + * replacing the sentinel, which would otherwise leave the gateway protected + * by a publicly-known credential. + * + * This is a belt-and-suspenders complement to keeping `.env.example` blank: + * the example file alone does not protect users who follow an older doc + * snippet or copy a tutorial command line. + */ +const KNOWN_WEAK_GATEWAY_TOKENS: ReadonlySet = new Set([ + "change-me-to-a-long-random-token", +]); + +const KNOWN_WEAK_GATEWAY_PASSWORDS: ReadonlySet = new Set([ + "change-me-to-a-strong-password", // pragma: allowlist secret +]); + export function mergeGatewayAuthConfig( base?: GatewayAuthConfig, override?: GatewayAuthConfig, @@ -196,6 +216,7 @@ export async function ensureGatewayStartupAuth(params: { tailscaleOverride: params.tailscaleOverride, }); if (resolved.mode !== "token" || (resolved.token?.trim().length ?? 0) > 0) { + assertGatewayAuthNotKnownWeak(resolved); assertHooksTokenSeparateFromGatewayAuth({ cfg: params.cfg, auth: resolved }); return { cfg: params.cfg, auth: resolved, persistedGeneratedToken: false }; } @@ -229,6 +250,11 @@ export async function ensureGatewayStartupAuth(params: { authOverride: params.authOverride, tailscaleOverride: params.tailscaleOverride, }); + // The generated token is crypto-random, so this cannot match the weak set + // in practice — but running the assertion on both branches documents that + // the rule applies uniformly and guards against any future path that might + // feed a non-generated value through nextAuth. + assertGatewayAuthNotKnownWeak(nextAuth); assertHooksTokenSeparateFromGatewayAuth({ cfg: nextCfg, auth: nextAuth }); return { cfg: nextCfg, @@ -238,6 +264,31 @@ export async function ensureGatewayStartupAuth(params: { }; } +export function assertGatewayAuthNotKnownWeak(auth: ResolvedGatewayAuth): void { + if (auth.mode === "token") { + const token = auth.token?.trim() ?? ""; + if (token && KNOWN_WEAK_GATEWAY_TOKENS.has(token)) { + throw new Error( + "Invalid config: gateway auth token is set to the example placeholder " + + "from .env.example. Generate a real secret (e.g. `openssl rand -hex 32`) " + + "and set OPENCLAW_GATEWAY_TOKEN or gateway.auth.token before starting " + + "the gateway.", + ); + } + return; + } + if (auth.mode === "password") { + const password = auth.password?.trim() ?? ""; + if (password && KNOWN_WEAK_GATEWAY_PASSWORDS.has(password)) { + throw new Error( + "Invalid config: gateway auth password is set to the example placeholder " + + "from .env.example. Choose a real password and set OPENCLAW_GATEWAY_PASSWORD " + + "or gateway.auth.password before starting the gateway.", + ); + } + } +} + export function assertHooksTokenSeparateFromGatewayAuth(params: { cfg: OpenClawConfig; auth: ResolvedGatewayAuth;