fix(gateway): reject known-weak example auth credentials at startup (#64586)

This commit is contained in:
Alex Navarro
2026-04-12 09:33:05 -06:00
committed by GitHub
parent 4904e15349
commit f3b636481f
4 changed files with 197 additions and 6 deletions

View File

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

View File

@@ -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/<id>/agent` but the matching `agents.list[]` entries are missing from config. (#65113) Thanks @neeravmakwana.

View File

@@ -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", () => {

View File

@@ -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<string> = new Set([
"change-me-to-a-long-random-token",
]);
const KNOWN_WEAK_GATEWAY_PASSWORDS: ReadonlySet<string> = 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;