fix: harden published gateway secret placeholders

This commit is contained in:
Peter Steinberger
2026-04-18 18:14:50 +01:00
parent 106b770c40
commit 630f2bcabe
8 changed files with 149 additions and 60 deletions

View File

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

View File

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

View File

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

View File

@@ -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<Array<{ docName: string; markdown: string }>> {
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");
}
});
});

View File

@@ -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<string> = new Set(
KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS,
);
const KNOWN_WEAK_GATEWAY_PASSWORDS: ReadonlySet<string> = 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.",
);
}
}
}

View File

@@ -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));

View File

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

View File

@@ -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<string> = new Set(
KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS,
);
const KNOWN_WEAK_GATEWAY_PASSWORDS: ReadonlySet<string> = 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;