From 29e41d4c0ac285a081b4eb602a7ec1d0f7bc89d3 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Feb 2026 11:05:27 +0100 Subject: [PATCH] fix: land security audit severity + temp-path guard fixes (#23428) (thanks @bmendonca3) --- CHANGELOG.md | 1 + extensions/feishu/src/dedup.ts | 3 +++ src/commands/sessions.test-helpers.ts | 3 ++- src/security/audit.test.ts | 34 +++++++++++++++++++++++ src/security/audit.ts | 39 ++++++++++++++++++++++++++- 5 files changed, 78 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3e15b1a8..fde13f274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai - Channels/Delivery: remove hardcoded WhatsApp delivery fallbacks; require explicit/session channel context or auto-pick the sole configured channel when unambiguous. (#23357) Thanks @lbo728. - ACP/Gateway: wait for gateway hello before opening ACP requests, and fail fast on pre-hello connect failures to avoid startup hangs and early `gateway not connected` request races. (#23390) Thanks @janckerchen. - Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). +- Security/Audit: make `gateway.real_ip_fallback_enabled` severity conditional for loopback trusted-proxy setups (warn for loopback-only `trustedProxies`, critical when non-loopback proxies are trusted). (#23428) Thanks @bmendonca3. - Security/Exec env: block request-scoped `HOME` and `ZDOTDIR` overrides in host exec env sanitizers (Node + macOS), preventing shell startup-file execution before allowlist-evaluated command bodies. This ships in the next npm release. Thanks @tdjackey for reporting. - Security/Gateway: emit a startup security warning when insecure/dangerous config flags are enabled (including `gateway.controlUi.dangerouslyDisableDeviceAuth=true`) and point operators to `openclaw security audit`. - Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. This ships in the next npm release. Thanks @aether-ai-agent for reporting. diff --git a/extensions/feishu/src/dedup.ts b/extensions/feishu/src/dedup.ts index 6468e30f2..b0fa4ce16 100644 --- a/extensions/feishu/src/dedup.ts +++ b/extensions/feishu/src/dedup.ts @@ -14,6 +14,9 @@ function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string { if (stateOverride) { return stateOverride; } + if (env.VITEST || env.NODE_ENV === "test") { + return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-")); + } return path.join(os.homedir(), ".openclaw"); } diff --git a/src/commands/sessions.test-helpers.ts b/src/commands/sessions.test-helpers.ts index 4c0d8b0c4..d4c01efc8 100644 --- a/src/commands/sessions.test-helpers.ts +++ b/src/commands/sessions.test-helpers.ts @@ -50,7 +50,8 @@ export function makeRuntime(params?: { throwOnError?: boolean }): { } export function writeStore(data: unknown, prefix = "sessions"): string { - const file = path.join(os.tmpdir(), `${prefix}-${Date.now()}-${randomUUID()}.json`); + const fileName = `${[prefix, Date.now(), randomUUID()].join("-")}.json`; + const file = path.join(os.tmpdir(), fileName); fs.writeFileSync(file, JSON.stringify(data, null, 2)); return file; } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 0edb5d635..c8703341c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -1009,6 +1009,40 @@ describe("security audit", () => { }, expectedSeverity: "critical", }, + { + name: "loopback trusted-proxy with loopback-only proxies", + cfg: { + gateway: { + bind: "loopback", + allowRealIpFallback: true, + trustedProxies: ["127.0.0.1"], + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, + }, + expectedSeverity: "warn", + }, + { + name: "loopback trusted-proxy with non-loopback proxy range", + cfg: { + gateway: { + bind: "loopback", + allowRealIpFallback: true, + trustedProxies: ["127.0.0.1", "10.0.0.0/8"], + auth: { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + }, + }, + expectedSeverity: "critical", + }, ]; for (const testCase of cases) { diff --git a/src/security/audit.ts b/src/security/audit.ts index d47f3ef23..c02191cf3 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -1,3 +1,4 @@ +import { isIP } from "node:net"; import { resolveSandboxConfigForAgent } from "../agents/sandbox.js"; import { execDockerRaw } from "../agents/sandbox/docker.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; @@ -8,6 +9,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { resolveConfigPath, resolveStateDir } from "../config/paths.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { isLoopbackAddress } from "../gateway/net.js"; import { resolveGatewayProbeAuth } from "../gateway/probe-auth.js"; import { probeGateway } from "../gateway/probe.js"; import { collectChannelSecurityFindings } from "./audit-channel.js"; @@ -337,7 +339,11 @@ function collectGatewayConfigFindings( } if (allowRealIpFallback) { - const exposed = bind !== "loopback" || auth.mode === "trusted-proxy"; + const hasNonLoopbackTrustedProxy = trustedProxies.some( + (proxy) => !isLoopbackOnlyTrustedProxyEntry(proxy), + ); + const exposed = + bind !== "loopback" || (auth.mode === "trusted-proxy" && hasNonLoopbackTrustedProxy); findings.push({ checkId: "gateway.real_ip_fallback_enabled", severity: exposed ? "critical" : "warn", @@ -502,6 +508,37 @@ function collectGatewayConfigFindings( return findings; } +function isLoopbackOnlyTrustedProxyEntry(entry: string): boolean { + const candidate = entry.trim(); + if (!candidate) { + return false; + } + if (!candidate.includes("/")) { + return isLoopbackAddress(candidate); + } + + const [rawIp, rawPrefix] = candidate.split("/", 2); + if (!rawIp || !rawPrefix) { + return false; + } + const ipVersion = isIP(rawIp.trim()); + const prefix = Number.parseInt(rawPrefix.trim(), 10); + if (!Number.isInteger(prefix)) { + return false; + } + if (ipVersion === 4) { + if (prefix < 8 || prefix > 32) { + return false; + } + const firstOctet = Number.parseInt(rawIp.trim().split(".")[0] ?? "", 10); + return firstOctet === 127; + } + if (ipVersion === 6) { + return prefix === 128 && rawIp.trim().toLowerCase() === "::1"; + } + return false; +} + function collectBrowserControlFindings( cfg: OpenClawConfig, env: NodeJS.ProcessEnv,