fix: Hook ingress token unlocks password-mode gateway auth (#86453)

Summary:
- The PR expands security audit, CLI docs, and tests so `hooks.token` reuse of active Gateway token/password auth is reported while password-mode Gateway startup remains compatible.
- PR surface: Source +178, Tests +311, Docs +14. Total +503 across 14 files.
- Reproducibility: yes. from source inspection: current main forwards a bearer token as both token and passwor ... ecause this review was read-only, but the linked issue and code path make the reproduction high confidence.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(cr-fmi-hook-ingress-token-unlocks-password-mode-gateway-auth): ap…
- PR branch already contained follow-up commit before automerge: fix: include trusted proxy password in hooks token reuse check
- PR branch already contained follow-up commit before automerge: fix(gateway): audit hooks password reuse without blocking startup
- PR branch already contained follow-up commit before automerge: fix: Hook ingress token unlocks password-mode gateway auth

Validation:
- ClawSweeper review passed for head 7c796b22ec.
- Required merge gates passed before the squash merge.

Prepared head SHA: 7c796b22ec
Review: https://github.com/openclaw/openclaw/pull/86453#issuecomment-4533831028

Co-authored-by: Coy Geek <65363919+coygeek@users.noreply.github.com>
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: jesse-merhi
This commit is contained in:
clawsweeper[bot]
2026-05-25 13:39:56 +00:00
committed by GitHub
co-authored by Coy Geek jesse-merhi clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
parent d6b7fe8615
commit d51f26850d
14 changed files with 557 additions and 54 deletions
+1
View File
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Models: show OAuth delegation markers as configured `models.json` auth while keeping runtime route usability checks strict. (#86378) Thanks @rohitjavvadi.
- Cron: seed active scheduled and manual cron task rows with a progress summary so status surfaces do not look blank while jobs run. (#86313) Thanks @ferminquant.
- Updater: exclude prerelease git tags from stable channel resolution so source updates do not check out newer alpha/rc/preview/canary tags. (#86260) Thanks @stevenepalmer.
- Security/Audit: flag webhook `hooks.token` reuse of active Gateway password auth in `openclaw security audit` while keeping password-mode startup compatibility. (#84338) Thanks @coygeek.
- Agents/heartbeat: stop heartbeat turns after the first valid `heartbeat_respond` so repeated response loops do not burn tokens. (#86357) Thanks @udaymanish6.
- Tasks: keep retained lost tasks out of default status health counts, explain their cleanup window during maintenance, and prune lost task records after 24 hours instead of the general 7-day terminal retention.
- Memory-core: keep REM dreaming focused on live light-staged memories and mark staged entries as considered so old recall history no longer dominates fresh candidates. (#86302) Thanks @SebTardif.
+13 -1
View File
@@ -32,7 +32,19 @@ This is for cooperative/shared inbox hardening. A single Gateway shared by mutua
It also emits `security.trust_model.multi_user_heuristic` when config suggests likely shared-user ingress (for example open DM/group policy, configured group targets, or wildcard sender rules), and reminds you that OpenClaw is a personal-assistant trust model by default.
For intentional shared-user setups, the audit guidance is to sandbox all sessions, keep filesystem access workspace-scoped, and keep personal/private identities or credentials off that runtime.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, it warns when `hooks.token` reuses the Gateway token, when `hooks.token` is short, when `hooks.path="/"`, when `hooks.defaultSessionKey` is unset, when `hooks.allowedAgentIds` is unrestricted, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
For webhook ingress, it warns when:
- `hooks.token` reuses an active Gateway shared-secret auth value (`gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`)
- `hooks.token` is short
- `hooks.path="/"`
- `hooks.defaultSessionKey` is unset
- `hooks.allowedAgentIds` is unrestricted
- request `sessionKey` overrides are enabled
- overrides are enabled without `hooks.allowedSessionKeyPrefixes`
If Gateway password auth is supplied only at startup, pass the same value to `openclaw security audit --auth password --password <password>` so the audit can check it against `hooks.token`.
Password-mode reuse is an audit finding for compatibility; rotate one of the secrets instead of expecting Gateway startup to reject that configuration.
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries (exact node command-name matching only, not shell-text filtering), when `gateway.nodes.allowCommands` explicitly enables dangerous node commands, when global `tools.profile="minimal"` is overridden by agent tool profiles, when write/edit tools are disabled but `exec` is still available without a constraining sandbox filesystem boundary, when open groups expose runtime/filesystem tools without sandbox/workspace guards, and when installed plugin tools may be reachable under permissive tool policy.
It also flags `gateway.allowRealIpFallback=true` (header-spoofing risk if proxies are misconfigured) and `discovery.mdns.mode="full"` (metadata leakage via mDNS TXT records).
It also warns when sandbox browser uses Docker `bridge` network without `sandbox.browser.cdpSourceRange`.
+2 -1
View File
@@ -687,7 +687,8 @@ Query-string hook tokens are rejected.
Validation and safety notes:
- `hooks.enabled=true` requires a non-empty `hooks.token`.
- `hooks.token` must be **distinct** from `gateway.auth.token`; reusing the Gateway token is rejected.
- `hooks.token` must be distinct from `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`; reusing the Gateway token fails startup validation.
- `openclaw security audit` also flags `hooks.token` reuse of active Gateway password auth (`gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`, or `--auth password --password <password>`) as a critical finding; password-mode reuse stays startup-compatible and should be repaired by rotating one of the secrets.
- `hooks.path` cannot be `/`; use a dedicated subpath such as `/hooks`.
- If `hooks.allowRequestSessionKey=true`, constrain `hooks.allowedSessionKeyPrefixes` (for example `["hook:"]`).
- If a mapping or preset uses a templated `sessionKey`, set `hooks.allowedSessionKeyPrefixes` and `hooks.allowRequestSessionKey=true`. Static mapping keys do not require that opt-in.
+1 -1
View File
@@ -461,7 +461,7 @@ candidate contains redacted secret placeholders such as `***`.
Security note:
- Treat all hook/webhook payload content as untrusted input.
- Use a dedicated `hooks.token`; do not reuse the shared Gateway token.
- Use a dedicated `hooks.token`; do not reuse active Gateway auth secrets (`gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN` or `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD`).
- Hook auth is header-only (`Authorization: Bearer ...` or `x-openclaw-token`); query-string tokens are rejected.
- `hooks.path` cannot be `/`; keep webhook ingress on a dedicated subpath such as `/hooks`.
- Keep unsafe-content bypass flags disabled (`hooks.gmail.allowUnsafeExternalContent`, `hooks.mappings[].allowUnsafeExternalContent`) unless doing tightly scoped debugging.
+1 -1
View File
@@ -64,7 +64,7 @@ exhaustive):
| `security.audit.suppressions.active` | info | Audit output has configured suppressions and may be filtered | `security.audit.suppressions` | no |
| `config.secrets.gateway_password_in_config` | warn | Gateway password is stored directly in config | `gateway.auth.password` | no |
| `config.secrets.hooks_token_in_config` | warn | Hook bearer token is stored directly in config | `hooks.token` | no |
| `hooks.token_reuse_gateway_token` | critical | Hook ingress token also unlocks Gateway auth | `hooks.token`, `gateway.auth.token` | no |
| `hooks.token_reuse_gateway_token` | critical | Hook ingress token also unlocks Gateway auth | `hooks.token`, `gateway.auth.token`, `gateway.auth.password` | no |
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
| `hooks.default_session_key_unset` | warn | Hook agent runs fan out into generated per-request sessions | `hooks.defaultSessionKey` | no |
| `hooks.allowed_agent_ids_unrestricted` | warn/critical | Authenticated hook callers may route to any configured agent | `hooks.allowedAgentIds` | no |
+32 -1
View File
@@ -170,11 +170,19 @@ describe("security CLI", () => {
title: "forwards --token to deep probe auth without altering command-level resolver mode",
argv: ["--token", "explicit-token"],
deepProbeAuth: { token: "explicit-token" },
auditGatewayAuthOverride: undefined,
},
{
title: "forwards --password to deep probe auth without altering command-level resolver mode",
argv: ["--password", "explicit-password"],
deepProbeAuth: { password: "explicit-password" },
auditGatewayAuthOverride: undefined,
},
{
title: "forwards --auth with explicit gateway password",
argv: ["--auth", "password", "--password", "explicit-password"],
deepProbeAuth: { password: "explicit-password" },
auditGatewayAuthOverride: { mode: "password", password: "explicit-password" },
},
{
title: "forwards both --token and --password to deep probe auth",
@@ -183,8 +191,9 @@ describe("security CLI", () => {
token: "explicit-token",
password: "explicit-password",
},
auditGatewayAuthOverride: undefined,
},
])("$title", async ({ argv, deepProbeAuth }) => {
])("$title", async ({ argv, deepProbeAuth, auditGatewayAuthOverride }) => {
primeDeepAuditConfig();
await createProgram().parseAsync(["security", "audit", "--deep", ...argv, "--json"], {
@@ -194,5 +203,27 @@ describe("security CLI", () => {
expect(lastSecretResolverOptions()?.mode).toBe("read_only_status");
expect(lastSecurityAuditOptions()?.deep).toBe(true);
expect(lastSecurityAuditOptions()?.deepProbeAuth).toEqual(deepProbeAuth);
expect(lastSecurityAuditOptions()?.auditGatewayAuthOverride).toEqual(auditGatewayAuthOverride);
});
it.each([
{
argv: ["--auth", "token"],
message: /pass --token <token>/i,
},
{
argv: ["--auth", "password"],
message: /pass --password <password>/i,
},
])(
"rejects shared-secret auth override without the matching secret",
async ({ argv, message }) => {
primeDeepAuditConfig();
await expect(
createProgram().parseAsync(["security", "audit", ...argv, "--json"], { from: "user" }),
).rejects.toThrow(message);
expect(runSecurityAudit).not.toHaveBeenCalled();
},
);
});
+59 -2
View File
@@ -1,9 +1,13 @@
import type { Command } from "commander";
import { getRuntimeConfig } from "../config/config.js";
import type { GatewayAuthMode } from "../config/types.gateway.js";
import { defaultRuntime } from "../runtime.js";
import { runSecurityAudit } from "../security/audit.js";
import { fixSecurityFootguns } from "../security/fix.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../shared/string-coerce.js";
import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
@@ -16,10 +20,45 @@ type SecurityAuditOptions = {
json?: boolean;
deep?: boolean;
fix?: boolean;
auth?: string;
token?: string;
password?: string;
};
function parseGatewayAuthMode(value: string | undefined): GatewayAuthMode | undefined {
const mode = normalizeOptionalLowercaseString(value);
if (!mode) {
return undefined;
}
if (mode === "none" || mode === "token" || mode === "password" || mode === "trusted-proxy") {
return mode;
}
throw new Error(
'Invalid --auth value. Expected "none", "token", "password", or "trusted-proxy".',
);
}
function buildAuditGatewayAuthOverride(params: {
mode?: GatewayAuthMode;
token?: string;
password?: string;
}) {
if (!params.mode) {
return undefined;
}
if (params.mode === "token" && !params.token) {
throw new Error("Invalid --auth token: pass --token <token> for audit auth override.");
}
if (params.mode === "password" && !params.password) {
throw new Error("Invalid --auth password: pass --password <password> for audit auth override.");
}
return {
mode: params.mode,
...(params.token ? { token: params.token } : {}),
...(params.password ? { password: params.password } : {}),
};
}
function formatSummary(summary: { critical: number; warn: number; info: number }): string {
const rich = isRich();
const c = summary.critical;
@@ -50,6 +89,10 @@ export function registerSecurityCli(program: Command) {
"openclaw security audit --deep --password <password>",
"Use explicit password for deep probe.",
],
[
"openclaw security audit --auth password --password <password>",
"Audit a runtime-only password-mode Gateway secret.",
],
["openclaw security audit --fix", "Apply safe remediations and file-permission fixes."],
["openclaw security audit --json", "Output machine-readable JSON."],
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.openclaw.ai/cli/security")}\n`,
@@ -59,13 +102,23 @@ export function registerSecurityCli(program: Command) {
.command("audit")
.description("Audit config + local state for common security foot-guns")
.option("--deep", "Attempt live Gateway probes and plugin-owned collector checks", false)
.option(
"--auth <mode>",
'Runtime gateway auth mode ("none"|"token"|"password"|"trusted-proxy")',
)
.option("--token <token>", "Use explicit gateway token for deep probe auth")
.option("--password <password>", "Use explicit gateway password for deep probe auth")
.option("--fix", "Apply safe fixes (tighten defaults + chmod state/config)", false)
.option("--json", "Print JSON", false)
.action(async (opts: SecurityAuditOptions) => {
const authMode = parseGatewayAuthMode(opts.auth);
const token = normalizeOptionalString(opts.token);
const password = normalizeOptionalString(opts.password);
const auditGatewayAuthOverride = buildAuditGatewayAuthOverride({
mode: authMode,
token,
password,
});
const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null;
const sourceConfig = getRuntimeConfig();
@@ -84,8 +137,12 @@ export function registerSecurityCli(program: Command) {
includeChannelSecurity: true,
deepProbeAuth:
token || password
? { ...(token ? { token } : {}), ...(password ? { password } : {}) }
? {
...(token ? { token } : {}),
...(password ? { password } : {}),
}
: undefined,
auditGatewayAuthOverride,
});
if (opts.json) {
+63 -3
View File
@@ -414,6 +414,27 @@ describe("ensureGatewayStartupAuth", () => {
).rejects.toThrow(/hooks\.token must not match gateway auth token/i);
});
it("does not block startup when hooks token reuses gateway password auth", async () => {
const result = await ensureGatewayStartupAuth({
cfg: {
hooks: {
enabled: true,
token: "shared-gateway-password-1234567890",
},
gateway: {
auth: {
mode: "password",
password: "shared-gateway-password-1234567890", // pragma: allowlist secret
},
},
},
env: {} as NodeJS.ProcessEnv,
});
expect(result.auth.mode).toBe("password");
expect(result.generatedToken).toBeUndefined();
});
it.each(KNOWN_WEAK_GATEWAY_TOKEN_PLACEHOLDERS)(
"rejects the published placeholder token %s supplied via environment",
async (token) => {
@@ -580,19 +601,58 @@ describe("assertHooksTokenSeparateFromGatewayAuth", () => {
).toThrow(/hooks\.token must not match gateway auth token/i);
});
it("allows hooks token when gateway auth is not token mode", () => {
it("allows hooks token reuse of gateway password auth", () => {
expect(
assertHooksTokenSeparateFromGatewayAuth({
cfg: {
hooks: {
enabled: true,
token: "shared-gateway-token-1234567890",
token: "shared-gateway-password-1234567890",
},
},
auth: {
mode: "password",
modeSource: "config",
password: "pw", // pragma: allowlist secret
password: "shared-gateway-password-1234567890", // pragma: allowlist secret
allowTailscale: false,
},
}),
).toBeUndefined();
});
it("allows hooks token reuse of trusted-proxy local password fallback", () => {
expect(
assertHooksTokenSeparateFromGatewayAuth({
cfg: {
hooks: {
enabled: true,
token: "trusted-proxy-local-password-1234567890",
},
},
auth: {
mode: "trusted-proxy",
modeSource: "config",
trustedProxy: { userHeader: "x-forwarded-user" },
password: "trusted-proxy-local-password-1234567890", // pragma: allowlist secret
allowTailscale: false,
},
}),
).toBeUndefined();
});
it("allows distinct hooks token when gateway auth is password mode", () => {
expect(
assertHooksTokenSeparateFromGatewayAuth({
cfg: {
hooks: {
enabled: true,
token: "hook-token-1234567890",
},
},
auth: {
mode: "password",
modeSource: "config",
password: "gateway-password-1234567890", // pragma: allowlist secret
allowTailscale: false,
},
}),
+111 -21
View File
@@ -6,9 +6,10 @@ import { getBlockedBindReason } from "../agents/sandbox/validate-sandbox-securit
import { isToolAllowedByPolicies } from "../agents/tool-policy-match.js";
import { resolveToolProfilePolicy } from "../agents/tool-policy.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { GatewayAuthConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { AgentToolsConfig } from "../config/types.tools.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { resolveGatewayAuth, type ResolvedGatewayAuth } from "../gateway/auth.js";
import { resolveAllowedAgentIds } from "../gateway/hooks-policy.js";
import {
DEFAULT_DANGEROUS_NODE_COMMANDS,
@@ -37,6 +38,20 @@ export type SecurityAuditFinding = {
remediation?: string;
};
export type HooksHardeningAuditOptions = {
gatewayAuthOverride?: Pick<GatewayAuthConfig, "mode" | "token" | "password">;
};
export type GatewayHttpNoAuthAuditOptions = {
gatewayAuthOverride?: Pick<GatewayAuthConfig, "mode" | "token" | "password">;
};
type GatewayAuthSharedSecretLabel = "gateway auth token" | "gateway auth password";
type ActiveGatewaySharedSecret = {
label: GatewayAuthSharedSecretLabel;
value?: string;
};
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
@@ -66,6 +81,73 @@ function isGatewayRemotelyExposed(cfg: OpenClawConfig): boolean {
return tailscaleMode === "serve" || tailscaleMode === "funnel";
}
function formatGatewayAuthDisplayLabel(label: GatewayAuthSharedSecretLabel): string {
if (label === "gateway auth password") {
return "Gateway password";
}
return "Gateway token";
}
function formatHooksTokenReuseDetail(reusedGatewayAuthLabel: GatewayAuthSharedSecretLabel): string {
if (reusedGatewayAuthLabel === "gateway auth password") {
return "hooks.token matches gateway.auth password; compromise of hooks expands blast radius to Gateway password auth.";
}
return "hooks.token matches gateway.auth token; compromise of hooks expands blast radius to the Gateway API.";
}
function listActiveGatewaySharedSecrets(auth: ResolvedGatewayAuth): ActiveGatewaySharedSecret[] {
if (auth.mode === "token") {
return [{ label: "gateway auth token", value: auth.token }];
}
if (auth.mode === "password" || auth.mode === "trusted-proxy") {
return [{ label: "gateway auth password", value: auth.password }];
}
return [];
}
function findGatewayAuthLabelMatchingHooksToken(params: {
hooksToken: string;
auth: ResolvedGatewayAuth;
}): GatewayAuthSharedSecretLabel | undefined {
return listActiveGatewaySharedSecrets(params.auth).find(
(candidate) => normalizeOptionalString(candidate.value) === params.hooksToken,
)?.label;
}
function findHooksTokenGatewayAuthReuseLabel(params: {
hooksToken: string;
configGatewayAuth: ResolvedGatewayAuth;
overrideGatewayAuth?: ResolvedGatewayAuth;
}): GatewayAuthSharedSecretLabel | undefined {
const configReuseLabel = findGatewayAuthLabelMatchingHooksToken({
hooksToken: params.hooksToken,
auth: params.configGatewayAuth,
});
if (configReuseLabel) {
return configReuseLabel;
}
return params.overrideGatewayAuth
? findGatewayAuthLabelMatchingHooksToken({
hooksToken: params.hooksToken,
auth: params.overrideGatewayAuth,
})
: undefined;
}
function hasResolvedGatewayHttpAuth(auth: ResolvedGatewayAuth): boolean {
if (auth.mode === "token") {
return Boolean(normalizeOptionalString(auth.token));
}
if (auth.mode === "password") {
return Boolean(normalizeOptionalString(auth.password));
}
if (auth.mode === "trusted-proxy") {
return true;
}
return false;
}
const LEGACY_MODEL_PATTERNS: Array<{ id: string; re: RegExp; label: string }> = [
{ id: "openai.gpt35", re: /\bgpt-3\.5\b/i, label: "GPT-3.5 family" },
{ id: "anthropic.claude2", re: /\bclaude-(instant|2)\b/i, label: "Claude 2/Instant family" },
@@ -497,6 +579,7 @@ export function collectSecretsInConfigFindings(cfg: OpenClawConfig): SecurityAud
export function collectHooksHardeningFindings(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
options: HooksHardeningAuditOptions = {},
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
if (cfg.hooks?.enabled !== true) {
@@ -513,31 +596,32 @@ export function collectHooksHardeningFindings(
});
}
const gatewayAuth = resolveGatewayAuth({
const configGatewayAuth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
env,
});
const openclawGatewayToken =
typeof env.OPENCLAW_GATEWAY_TOKEN === "string" && env.OPENCLAW_GATEWAY_TOKEN.trim()
? env.OPENCLAW_GATEWAY_TOKEN.trim()
: null;
const gatewayToken =
gatewayAuth.mode === "token" &&
typeof gatewayAuth.token === "string" &&
gatewayAuth.token.trim()
? gatewayAuth.token.trim()
: openclawGatewayToken
? openclawGatewayToken
: null;
if (token && gatewayToken && token === gatewayToken) {
const overrideGatewayAuth = options.gatewayAuthOverride
? resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
authOverride: options.gatewayAuthOverride,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
env,
})
: undefined;
const reusedGatewayAuthLabel = findHooksTokenGatewayAuthReuseLabel({
hooksToken: token,
configGatewayAuth,
overrideGatewayAuth,
});
if (reusedGatewayAuthLabel) {
findings.push({
checkId: "hooks.token_reuse_gateway_token",
severity: "critical",
title: "Hooks token reuses the Gateway token",
detail:
"hooks.token matches gateway.auth token; compromise of hooks expands blast radius to the Gateway API.",
remediation: "Use a separate hooks.token dedicated to hook ingress.",
title: `Hooks token reuses the ${formatGatewayAuthDisplayLabel(reusedGatewayAuthLabel)}`,
detail: formatHooksTokenReuseDetail(reusedGatewayAuthLabel),
remediation:
"Use a separate hooks.token dedicated to hook ingress and keep it distinct from Gateway token/password shared-secret auth.",
});
}
@@ -642,11 +726,17 @@ export function collectGatewayHttpSessionKeyOverrideFindings(
export function collectGatewayHttpNoAuthFindings(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv,
options: GatewayHttpNoAuthAuditOptions = {},
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
if (auth.mode !== "none") {
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
authOverride: options.gatewayAuthOverride,
tailscaleMode,
env,
});
if (hasResolvedGatewayHttpAuth(auth)) {
return findings;
}
+9 -2
View File
@@ -1,4 +1,5 @@
import { isIP } from "node:net";
import type { GatewayAuthConfig } from "../config/types.gateway.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth-resolve.js";
@@ -15,6 +16,7 @@ type CollectDangerousConfigFlags = (cfg: OpenClawConfig) => string[];
export type CollectGatewayConfigFindingsOptions = {
collectDangerousConfigFlags?: CollectDangerousConfigFlags;
gatewayAuthOverride?: Pick<GatewayAuthConfig, "mode" | "token" | "password">;
};
function hasNonEmptyString(value: unknown): value is string {
@@ -31,7 +33,12 @@ export function collectGatewayConfigFindings(
const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback";
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env });
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
authOverride: options.gatewayAuthOverride,
tailscaleMode,
env,
});
const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const controlUiAllowedOrigins = (cfg.gateway?.controlUi?.allowedOrigins ?? [])
.map((value) => value.trim())
@@ -57,7 +64,7 @@ export function collectGatewayConfigFindings(
sourceConfig.gateway?.remote?.token,
sourceConfig.secrets?.defaults,
);
const explicitAuthMode = sourceConfig.gateway?.auth?.mode;
const explicitAuthMode = options.gatewayAuthOverride?.mode ?? sourceConfig.gateway?.auth?.mode;
const tokenCanWin =
hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured;
const passwordCanWin =
+58 -16
View File
@@ -62,6 +62,45 @@ describe("security audit gateway HTTP auth findings", () => {
expectedNoFinding: "gateway.http.no_auth",
env: {} as NodeJS.ProcessEnv,
},
{
name: "does not report gateway.http.no_auth with runtime password auth override",
cfg: {
gateway: {
bind: "loopback",
auth: { mode: "none" },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expectedNoFinding: "gateway.http.no_auth",
env: {} as NodeJS.ProcessEnv,
gatewayAuthOverride: {
mode: "password" as const,
password: "runtime-gateway-password-1234567890", // pragma: allowlist secret
},
},
{
name: "reports gateway.http.no_auth when runtime password mode lacks a password",
cfg: {
gateway: {
bind: "loopback",
auth: { mode: "none" },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
} satisfies OpenClawConfig,
expectedFinding: { checkId: "gateway.http.no_auth", severity: "warn" as const },
env: {} as NodeJS.ProcessEnv,
gatewayAuthOverride: {
mode: "password" as const,
},
},
{
name: "reports HTTP API session-key override surfaces when enabled",
cfg: {
@@ -79,23 +118,26 @@ describe("security audit gateway HTTP auth findings", () => {
severity: "info" as const,
},
},
])("$name", ({ cfg, expectedFinding, expectedNoFinding, detailIncludes, env }) => {
const findings = [
...collectGatewayHttpNoAuthFindings(cfg, env ?? process.env),
...collectGatewayHttpSessionKeyOverrideFindings(cfg),
];
])(
"$name",
({ cfg, expectedFinding, expectedNoFinding, detailIncludes, env, gatewayAuthOverride }) => {
const findings = [
...collectGatewayHttpNoAuthFindings(cfg, env ?? process.env, { gatewayAuthOverride }),
...collectGatewayHttpSessionKeyOverrideFindings(cfg),
];
if (expectedFinding) {
const finding = requireFinding(findings, expectedFinding.checkId);
expect(finding.severity).toBe(expectedFinding.severity);
if (detailIncludes) {
for (const text of detailIncludes) {
expect(finding.detail, `${expectedFinding.checkId}:${text}`).toContain(text);
if (expectedFinding) {
const finding = requireFinding(findings, expectedFinding.checkId);
expect(finding.severity).toBe(expectedFinding.severity);
if (detailIncludes) {
for (const text of detailIncludes) {
expect(finding.detail, `${expectedFinding.checkId}:${text}`).toContain(text);
}
}
}
}
if (expectedNoFinding) {
expect(findings.map((entry) => entry.checkId)).not.toContain(expectedNoFinding);
}
});
if (expectedNoFinding) {
expect(findings.map((entry) => entry.checkId)).not.toContain(expectedNoFinding);
}
},
);
});
+23
View File
@@ -112,6 +112,29 @@ describe("security audit gateway config findings", () => {
]);
});
it("honors runtime password auth override for bind auth checks", () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
auth: {},
},
};
const findings = collectGatewayConfigFindings(
cfg,
cfg,
{},
{
gatewayAuthOverride: {
mode: "password",
password: "runtime-gateway-password-1234567890", // pragma: allowlist secret
},
},
);
expect(hasFinding("gateway.bind_no_auth", findings)).toBe(false);
});
it("warns when OPENCLAW_GATEWAY_TOKEN shadows a different configured token source", () => {
const cfg: OpenClawConfig = {
gateway: { auth: { token: "config-token" } },
+155
View File
@@ -10,6 +10,10 @@ function hasFinding(
return findings.some((finding) => finding.checkId === checkId && finding.severity === severity);
}
function getFinding(findings: ReturnType<typeof collectHooksHardeningFindings>, checkId: string) {
return findings.find((finding) => finding.checkId === checkId);
}
describe("security audit hooks ingress findings", () => {
it("evaluates hooks ingress auth and routing findings", () => {
const unrestrictedBaseHooks = {
@@ -114,4 +118,155 @@ describe("security audit hooks ingress findings", () => {
}
}
});
it("flags hooks token reuse of gateway password auth as critical", () => {
const findings = collectHooksHardeningFindings({
gateway: {
auth: {
mode: "password",
password: "shared-gateway-password-1234567890", // pragma: allowlist secret
},
},
hooks: {
enabled: true,
token: "shared-gateway-password-1234567890",
},
});
expect(hasFinding(findings, "hooks.token_reuse_gateway_token", "critical")).toBe(true);
const finding = getFinding(findings, "hooks.token_reuse_gateway_token");
expect(finding?.title).toContain("Gateway password");
expect(finding?.detail).toContain("gateway.auth password");
expect(finding?.remediation).toContain("Gateway token/password");
});
it("flags hooks token reuse of trusted-proxy local password fallback as critical", () => {
const findings = collectHooksHardeningFindings({
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: { userHeader: "x-forwarded-user" },
password: "trusted-proxy-local-password-1234567890", // pragma: allowlist secret
},
},
hooks: {
enabled: true,
token: "trusted-proxy-local-password-1234567890",
},
});
expect(hasFinding(findings, "hooks.token_reuse_gateway_token", "critical")).toBe(true);
const finding = getFinding(findings, "hooks.token_reuse_gateway_token");
expect(finding?.title).toContain("Gateway password");
expect(finding?.detail).toContain("gateway.auth password");
});
it("flags hooks token reuse of an explicit audit password override as critical", () => {
const findings = collectHooksHardeningFindings(
{
hooks: {
enabled: true,
token: "runtime-only-gateway-password-1234567890",
},
},
{} as NodeJS.ProcessEnv,
{
gatewayAuthOverride: {
password: "runtime-only-gateway-password-1234567890", // pragma: allowlist secret
},
},
);
expect(hasFinding(findings, "hooks.token_reuse_gateway_token", "critical")).toBe(true);
const finding = getFinding(findings, "hooks.token_reuse_gateway_token");
expect(finding?.title).toContain("Gateway password");
expect(finding?.detail).toContain("gateway.auth password");
});
it("does not flag inactive explicit audit password when config mode is token", () => {
const findings = collectHooksHardeningFindings(
{
gateway: {
auth: {
mode: "token",
token: "config-gateway-token-1234567890", // pragma: allowlist secret
},
},
hooks: {
enabled: true,
token: "runtime-only-gateway-password-1234567890",
},
},
{} as NodeJS.ProcessEnv,
{
gatewayAuthOverride: {
password: "runtime-only-gateway-password-1234567890", // pragma: allowlist secret
},
},
);
expect(hasFinding(findings, "hooks.token_reuse_gateway_token", "critical")).toBe(false);
});
it("flags explicit audit password reuse when config mode is token", () => {
const findings = collectHooksHardeningFindings(
{
gateway: {
auth: {
mode: "token",
token: "config-gateway-token-1234567890", // pragma: allowlist secret
},
},
hooks: {
enabled: true,
token: "runtime-only-gateway-password-1234567890",
},
},
{} as NodeJS.ProcessEnv,
{
gatewayAuthOverride: {
mode: "password",
password: "runtime-only-gateway-password-1234567890", // pragma: allowlist secret
},
},
);
expect(hasFinding(findings, "hooks.token_reuse_gateway_token", "critical")).toBe(true);
const finding = getFinding(findings, "hooks.token_reuse_gateway_token");
expect(finding?.title).toContain("Gateway password");
expect(finding?.detail).toContain("gateway.auth password");
});
it("keeps config password reuse finding when explicit audit password differs", () => {
const findings = collectHooksHardeningFindings(
{
gateway: {
auth: {
mode: "password",
password: "config-gateway-password-1234567890", // pragma: allowlist secret
},
},
hooks: {
enabled: true,
token: "config-gateway-password-1234567890",
},
},
{} as NodeJS.ProcessEnv,
{
gatewayAuthOverride: {
password: "different-runtime-password-1234567890", // pragma: allowlist secret
},
},
);
expect(hasFinding(findings, "hooks.token_reuse_gateway_token", "critical")).toBe(true);
const finding = getFinding(findings, "hooks.token_reuse_gateway_token");
expect(finding?.title).toContain("Gateway password");
expect(finding?.detail).toContain("gateway.auth password");
});
});
+29 -5
View File
@@ -4,6 +4,7 @@ import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import type { GatewayAuthConfig } from "../config/types.gateway.js";
import type { SecurityAuditSuppression } from "../config/types.openclaw.js";
import { isInterpreterLikeAllowlistPattern } from "../infra/command-analysis/inline-eval.js";
import { type ExecApprovalsFile, loadExecApprovals } from "../infra/exec-approvals.js";
@@ -36,6 +37,11 @@ import type { ExecFn } from "./windows-acl.js";
type ExecDockerRawFn = typeof import("../agents/sandbox/docker.js").execDockerRaw;
type ProbeGatewayFn = typeof import("../gateway/probe.js").probeGateway;
type SecurityAuditExplicitGatewayAuth = {
token?: string;
password?: string;
};
type SecurityAuditGatewayAuthOverride = Pick<GatewayAuthConfig, "mode" | "token" | "password">;
export type {
SecurityAuditFinding,
@@ -71,7 +77,9 @@ export type SecurityAuditOptions = {
/** Optional cache for code-safety summaries across repeated deep audits. */
codeSafetySummaryCache?: Map<string, Promise<unknown>>;
/** Optional explicit auth for deep gateway probe. */
deepProbeAuth?: { token?: string; password?: string };
deepProbeAuth?: SecurityAuditExplicitGatewayAuth;
/** Optional explicit Gateway auth mode/secret for config-only audit checks. */
auditGatewayAuthOverride?: SecurityAuditGatewayAuthOverride;
/** Override workspace used for workspace plugin discovery. */
workspaceDir?: string;
/** Dependency injection for tests. */
@@ -96,7 +104,8 @@ export type AuditExecutionContext = {
loadPluginSecurityCollectors: boolean;
configSnapshot: ConfigFileSnapshot | null;
codeSafetySummaryCache: Map<string, Promise<unknown>>;
deepProbeAuth?: { token?: string; password?: string };
deepProbeAuth?: SecurityAuditExplicitGatewayAuth;
auditGatewayAuthOverride?: SecurityAuditGatewayAuthOverride;
workspaceDir?: string;
};
@@ -399,9 +408,11 @@ export function collectGatewayConfigFindings(
cfg: OpenClawConfig,
sourceConfig: OpenClawConfig,
env: NodeJS.ProcessEnv,
options: { gatewayAuthOverride?: SecurityAuditGatewayAuthOverride } = {},
): SecurityAuditFinding[] {
return collectGatewayConfigFindingsBase(cfg, sourceConfig, env, {
collectDangerousConfigFlags: collectEnabledInsecureOrDangerousFlags,
gatewayAuthOverride: options.gatewayAuthOverride,
});
}
@@ -1032,6 +1043,7 @@ async function createAuditExecutionContext(
configSnapshot,
codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map<string, Promise<unknown>>(),
deepProbeAuth: opts.deepProbeAuth,
auditGatewayAuthOverride: opts.auditGatewayAuthOverride,
};
}
@@ -1044,13 +1056,25 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
findings.push(...auditNonDeep.collectAttackSurfaceSummaryFindings(cfg));
findings.push(...auditNonDeep.collectSyncedFolderFindings({ stateDir, configPath }));
findings.push(...collectGatewayConfigFindings(cfg, context.sourceConfig, env));
findings.push(
...collectGatewayConfigFindings(cfg, context.sourceConfig, env, {
gatewayAuthOverride: context.auditGatewayAuthOverride,
}),
);
findings.push(...(await collectPluginSecurityAuditFindings(context)));
findings.push(...collectLoggingFindings(cfg));
findings.push(...collectElevatedFindings(cfg));
findings.push(...collectExecRuntimeFindings(cfg));
findings.push(...auditNonDeep.collectHooksHardeningFindings(cfg, env));
findings.push(...auditNonDeep.collectGatewayHttpNoAuthFindings(cfg, env));
findings.push(
...auditNonDeep.collectHooksHardeningFindings(cfg, env, {
gatewayAuthOverride: context.auditGatewayAuthOverride,
}),
);
findings.push(
...auditNonDeep.collectGatewayHttpNoAuthFindings(cfg, env, {
gatewayAuthOverride: context.auditGatewayAuthOverride,
}),
);
findings.push(...auditNonDeep.collectGatewayHttpSessionKeyOverrideFindings(cfg));
findings.push(...auditNonDeep.collectSandboxDockerNoopFindings(cfg));
findings.push(...auditNonDeep.collectSandboxDangerousConfigFindings(cfg));