diff --git a/CHANGELOG.md b/CHANGELOG.md index f30862c2f54..592fa8c5a55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai - Agents/Google: strip `thinkingBudget=0` for the thinking-required `gemini-2.5-pro` model in embedded-runner and native Google payloads, so requests no longer fail with `Budget 0 is invalid. This model only works in thinking mode.` and the API uses its default thinking behavior instead. (#68607) Thanks @josmithiii. - Slack/threads: log failed thread starter and history fetches at verbose level while preserving best-effort fallback behavior, so missing Slack thread context is diagnosable without interrupting inbound handling. (#68594) Thanks @martingarramon. - Gateway/restart: keep stale-gateway cleanup from terminating the current process's parent or ancestors, so plugin sidecars like WeChat no longer kill the active gateway and trigger an infinite supervisor restart loop. Fixes #68451. (#68517) Thanks @openperf. +- Gateway/auth: reject gateway auth credentials that match published example placeholders at startup and secret reload, and keep cloud install snippets from publishing copy-paste gateway/keyring secrets. (#68404) Thanks @coygeek. ## 2026.4.15 diff --git a/docs/install/gcp.md b/docs/install/gcp.md index 75e4e1959db..3736bfbb661 100644 --- a/docs/install/gcp.md +++ b/docs/install/gcp.md @@ -220,11 +220,14 @@ For the generic Docker flow, see [Docker](/install/docker). OPENCLAW_CONFIG_DIR=/home/$USER/.openclaw OPENCLAW_WORKSPACE_DIR=/home/$USER/.openclaw/workspace - GOG_KEYRING_PASSWORD=change-me-now + GOG_KEYRING_PASSWORD= XDG_CONFIG_HOME=/home/node/.openclaw ``` - Generate strong secrets: + Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to + manage it through `.env`; OpenClaw writes a random gateway token to + config on first start. Generate a keyring password and paste it into + `GOG_KEYRING_PASSWORD`: ```bash openssl rand -hex 32 diff --git a/docs/install/hetzner.md b/docs/install/hetzner.md index c0daadb93be..c5f5a4eb45a 100644 --- a/docs/install/hetzner.md +++ b/docs/install/hetzner.md @@ -141,11 +141,14 @@ For the generic Docker flow, see [Docker](/install/docker). OPENCLAW_CONFIG_DIR=/root/.openclaw OPENCLAW_WORKSPACE_DIR=/root/.openclaw/workspace - GOG_KEYRING_PASSWORD=change-me-now + GOG_KEYRING_PASSWORD= XDG_CONFIG_HOME=/home/node/.openclaw ``` - Generate strong secrets: + Leave `OPENCLAW_GATEWAY_TOKEN` blank unless you explicitly want to + manage it through `.env`; OpenClaw writes a random gateway token to + config on first start. Generate a keyring password and paste it into + `GOG_KEYRING_PASSWORD`: ```bash openssl rand -hex 32 diff --git a/src/docs/install-cloud-secrets.test.ts b/src/docs/install-cloud-secrets.test.ts index f5afa614702..1ff424edcaf 100644 --- a/src/docs/install-cloud-secrets.test.ts +++ b/src/docs/install-cloud-secrets.test.ts @@ -1,21 +1,42 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS } from "../gateway/known-weak-gateway-secrets.js"; +import { + KNOWN_WEAK_GATEWAY_PASSWORD_PLACEHOLDERS, + KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS, +} from "../gateway/known-weak-gateway-secrets.js"; const INSTALL_DOCS_DIR = path.join(process.cwd(), "docs", "install"); -const CLOUD_INSTALL_DOCS = ["gcp.md", "hetzner.md"] as const; +const CLOUD_DOCKER_VM_INSTALL_DOCS = new Set(["gcp.md", "hetzner.md"]); + +async function readInstallDocs(): Promise> { + const entries = await fs.readdir(INSTALL_DOCS_DIR, { withFileTypes: true }); + return await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .toSorted((a, b) => a.name.localeCompare(b.name)) + .map(async (entry) => ({ + docName: entry.name, + markdown: await fs.readFile(path.join(INSTALL_DOCS_DIR, entry.name), "utf8"), + })), + ); +} describe("cloud install docs", () => { it("does not publish a copy-paste gateway token placeholder", async () => { - for (const docName of CLOUD_INSTALL_DOCS) { - const markdown = await fs.readFile(path.join(INSTALL_DOCS_DIR, docName), "utf8"); - + for (const { docName, markdown } of await readInstallDocs()) { for (const token of KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS) { - expect(markdown).not.toContain(`OPENCLAW_GATEWAY_TOKEN=${token}`); + expect(markdown, docName).not.toContain(`OPENCLAW_GATEWAY_TOKEN=${token}`); + } + for (const password of KNOWN_WEAK_GATEWAY_PASSWORD_PLACEHOLDERS) { + expect(markdown, docName).not.toContain(`OPENCLAW_GATEWAY_PASSWORD=${password}`); + } + expect(markdown, docName).not.toMatch(/^ GOG_KEYRING_PASSWORD=change-me-now$/m); + if (CLOUD_DOCKER_VM_INSTALL_DOCS.has(docName)) { + expect(markdown, docName).toMatch(/^ OPENCLAW_GATEWAY_TOKEN=[ \t]*\r?$/m); + expect(markdown, docName).toMatch(/^ GOG_KEYRING_PASSWORD=[ \t]*\r?$/m); + expect(markdown, docName).toContain("openssl rand -hex 32"); } - expect(markdown).toMatch(/OPENCLAW_GATEWAY_TOKEN=[ \t]*\r?\n/); - expect(markdown).toContain("openssl rand -hex 32"); } }); }); diff --git a/src/gateway/known-weak-gateway-secrets.ts b/src/gateway/known-weak-gateway-secrets.ts index f820fd9bb53..6ad0eb4c925 100644 --- a/src/gateway/known-weak-gateway-secrets.ts +++ b/src/gateway/known-weak-gateway-secrets.ts @@ -1,6 +1,49 @@ +import type { ResolvedGatewayAuth } from "./auth.js"; + export const KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS = [ "change-me-to-a-long-random-token", "change-me-now", ] as const; export const KNOWN_WEAK_GATEWAY_PASSWORD_PLACEHOLDERS = ["change-me-to-a-strong-password"] as const; + +/** + * 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, reject it. 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. + */ +const KNOWN_WEAK_GATEWAY_TOKENS: ReadonlySet = new Set( + KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS, +); + +const KNOWN_WEAK_GATEWAY_PASSWORDS: ReadonlySet = new Set( + KNOWN_WEAK_GATEWAY_PASSWORD_PLACEHOLDERS, +); + +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 a published example placeholder " + + "from docs or .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.", + ); + } + } +} diff --git a/src/gateway/server-startup-config.secrets.test.ts b/src/gateway/server-startup-config.secrets.test.ts index f29c1a38e81..f2631263049 100644 --- a/src/gateway/server-startup-config.secrets.test.ts +++ b/src/gateway/server-startup-config.secrets.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js"; import type { PreparedSecretsRuntimeSnapshot, SecretResolverWarning } from "../secrets/runtime.js"; +import { KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS } from "./known-weak-gateway-secrets.js"; import { createRuntimeSecretsActivator, prepareGatewayStartupConfig, @@ -157,6 +158,56 @@ describe("gateway startup config secret preflight", () => { expect(emitStateEvent).not.toHaveBeenCalled(); }); + it.each(KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS)( + "rejects known weak gateway tokens resolved during secret activation: %s", + async (token) => { + const sourceConfig = gatewayTokenConfig({ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "GATEWAY_TOKEN_REF" }, + }, + }, + }); + const prepareRuntimeSecretsSnapshot = vi.fn(async () => + preparedSnapshot({ + ...sourceConfig, + gateway: { + ...sourceConfig.gateway, + auth: { + ...sourceConfig.gateway?.auth, + token, + }, + }, + }), + ); + const activateRuntimeSecretsSnapshot = vi.fn(); + const activateRuntimeSecrets = createRuntimeSecretsActivator({ + logSecrets: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + emitStateEvent: vi.fn(), + prepareRuntimeSecretsSnapshot, + activateRuntimeSecretsSnapshot, + }); + + await expect( + activateRuntimeSecrets(sourceConfig, { + reason: "reload", + activate: true, + }), + ).rejects.toThrow(/published example placeholder/); + expect(activateRuntimeSecretsSnapshot).not.toHaveBeenCalled(); + }, + ); + it("prunes channel refs from startup secret preflight when channels are skipped", async () => { process.env.OPENCLAW_SKIP_CHANNELS = "1"; const prepareRuntimeSecretsSnapshot = vi.fn(async ({ config }) => preparedSnapshot(config)); diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index 2043bc7e03b..c5d9dc1717c 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -20,6 +20,8 @@ import { activateSecretsRuntimeSnapshot, prepareSecretsRuntimeSnapshot, } from "../secrets/runtime.js"; +import { resolveGatewayAuth } from "./auth.js"; +import { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.js"; import { ensureGatewayStartupAuth, mergeGatewayAuthConfig, @@ -114,6 +116,7 @@ export function createRuntimeSecretsActivator(params: { const prepared = await prepareRuntimeSecretsSnapshot({ config: pruneSkippedStartupSecretSurfaces(config), }); + assertRuntimeGatewayAuthNotKnownWeak(prepared.config); if (activationParams.activate) { activateRuntimeSecretsSnapshot(prepared); logGatewayAuthSurfaceDiagnostics(prepared, params.logSecrets); @@ -241,6 +244,16 @@ function pruneSkippedStartupSecretSurfaces(config: OpenClawConfig): OpenClawConf }; } +function assertRuntimeGatewayAuthNotKnownWeak(config: OpenClawConfig): void { + assertGatewayAuthNotKnownWeak( + resolveGatewayAuth({ + authConfig: config.gateway?.auth, + env: process.env, + tailscaleMode: config.gateway?.tailscale?.mode ?? "off", + }), + ); +} + function logGatewayAuthSurfaceDiagnostics( prepared: { sourceConfig: OpenClawConfig; diff --git a/src/gateway/startup-auth.ts b/src/gateway/startup-auth.ts index 8ad3560e486..b3dbfedd394 100644 --- a/src/gateway/startup-auth.ts +++ b/src/gateway/startup-auth.ts @@ -18,30 +18,9 @@ import { hasGatewayTokenEnvCandidate, trimToUndefined, } from "./credentials.js"; -import { - KNOWN_WEAK_GATEWAY_PASSWORD_PLACEHOLDERS, - KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS, -} from "./known-weak-gateway-secrets.js"; +import { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.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( - KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS, -); - -const KNOWN_WEAK_GATEWAY_PASSWORDS: ReadonlySet = new Set( - KNOWN_WEAK_GATEWAY_PASSWORD_PLACEHOLDERS, -); +export { assertGatewayAuthNotKnownWeak } from "./known-weak-gateway-secrets.js"; export function mergeGatewayAuthConfig( base?: GatewayAuthConfig, @@ -268,31 +247,6 @@ 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 a published example placeholder " + - "from docs or .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;