perf(test): isolate gateway audit tests

This commit is contained in:
Peter Steinberger
2026-04-20 18:58:10 +01:00
parent f43e006529
commit 26c213031d
7 changed files with 558 additions and 511 deletions

127
src/gateway/auth-resolve.ts Normal file
View File

@@ -0,0 +1,127 @@
import type {
GatewayAuthConfig,
GatewayTailscaleMode,
GatewayTrustedProxyConfig,
} from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import { resolveGatewayCredentialsFromValues } from "./credentials.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
export type ResolvedGatewayAuthModeSource =
| "override"
| "config"
| "password"
| "token"
| "default";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
modeSource?: ResolvedGatewayAuthModeSource;
token?: string;
password?: string;
allowTailscale: boolean;
trustedProxy?: GatewayTrustedProxyConfig;
};
export type EffectiveSharedGatewayAuth = {
mode: "token" | "password";
secret: string | undefined;
};
export function resolveGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null;
authOverride?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv;
tailscaleMode?: GatewayTailscaleMode;
}): ResolvedGatewayAuth {
const baseAuthConfig = params.authConfig ?? {};
const authOverride = params.authOverride ?? undefined;
const authConfig: GatewayAuthConfig = { ...baseAuthConfig };
if (authOverride) {
if (authOverride.mode !== undefined) {
authConfig.mode = authOverride.mode;
}
if (authOverride.token !== undefined) {
authConfig.token = authOverride.token;
}
if (authOverride.password !== undefined) {
authConfig.password = authOverride.password;
}
if (authOverride.allowTailscale !== undefined) {
authConfig.allowTailscale = authOverride.allowTailscale;
}
if (authOverride.rateLimit !== undefined) {
authConfig.rateLimit = authOverride.rateLimit;
}
if (authOverride.trustedProxy !== undefined) {
authConfig.trustedProxy = authOverride.trustedProxy;
}
}
const env = params.env ?? process.env;
const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref;
const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref;
const resolvedCredentials = resolveGatewayCredentialsFromValues({
configToken: tokenRef ? undefined : authConfig.token,
configPassword: passwordRef ? undefined : authConfig.password,
env,
tokenPrecedence: "config-first",
passwordPrecedence: "config-first", // pragma: allowlist secret
});
const token = resolvedCredentials.token;
const password = resolvedCredentials.password;
const trustedProxy = authConfig.trustedProxy;
let mode: ResolvedGatewayAuth["mode"];
let modeSource: ResolvedGatewayAuth["modeSource"];
if (authOverride?.mode !== undefined) {
mode = authOverride.mode;
modeSource = "override";
} else if (authConfig.mode) {
mode = authConfig.mode;
modeSource = "config";
} else if (password) {
mode = "password";
modeSource = "password";
} else if (token) {
mode = "token";
modeSource = "token";
} else {
mode = "token";
modeSource = "default";
}
const allowTailscale =
authConfig.allowTailscale ??
(params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy");
return {
mode,
modeSource,
token,
password,
allowTailscale,
trustedProxy,
};
}
export function resolveEffectiveSharedGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null;
authOverride?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv;
tailscaleMode?: GatewayTailscaleMode;
}): EffectiveSharedGatewayAuth | null {
const resolvedAuth = resolveGatewayAuth(params);
if (resolvedAuth.mode === "token") {
return {
mode: "token",
secret: resolvedAuth.token,
};
}
if (resolvedAuth.mode === "password") {
return {
mode: "password",
secret: resolvedAuth.password,
};
}
return null;
}

View File

@@ -1,10 +1,5 @@
import type { IncomingMessage } from "node:http";
import type {
GatewayAuthConfig,
GatewayTailscaleMode,
GatewayTrustedProxyConfig,
} from "../config/config.js";
import { resolveSecretInputRef } from "../config/types.secrets.js";
import type { GatewayAuthConfig, GatewayTrustedProxyConfig } from "../config/config.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import {
@@ -16,7 +11,7 @@ import {
type AuthRateLimiter,
type RateLimitCheckResult,
} from "./auth-rate-limit.js";
import { resolveGatewayCredentialsFromValues } from "./credentials.js";
import { type ResolvedGatewayAuth } from "./auth-resolve.js";
import {
isLoopbackAddress,
resolveRequestClientIp,
@@ -25,28 +20,14 @@ import {
} from "./net.js";
import { checkBrowserOrigin } from "./origin-check.js";
import { withSerializedRateLimitAttempt } from "./rate-limit-attempt-serialization.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
export type ResolvedGatewayAuthModeSource =
| "override"
| "config"
| "password"
| "token"
| "default";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
modeSource?: ResolvedGatewayAuthModeSource;
token?: string;
password?: string;
allowTailscale: boolean;
trustedProxy?: GatewayTrustedProxyConfig;
};
export type EffectiveSharedGatewayAuth = {
mode: "token" | "password";
secret: string | undefined;
};
export {
resolveEffectiveSharedGatewayAuth,
resolveGatewayAuth,
type EffectiveSharedGatewayAuth,
type ResolvedGatewayAuth,
type ResolvedGatewayAuthMode,
type ResolvedGatewayAuthModeSource,
} from "./auth-resolve.js";
export type GatewayAuthResult = {
ok: boolean;
@@ -228,104 +209,6 @@ async function resolveVerifiedTailscaleUser(params: {
};
}
export function resolveGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null;
authOverride?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv;
tailscaleMode?: GatewayTailscaleMode;
}): ResolvedGatewayAuth {
const baseAuthConfig = params.authConfig ?? {};
const authOverride = params.authOverride ?? undefined;
const authConfig: GatewayAuthConfig = { ...baseAuthConfig };
if (authOverride) {
if (authOverride.mode !== undefined) {
authConfig.mode = authOverride.mode;
}
if (authOverride.token !== undefined) {
authConfig.token = authOverride.token;
}
if (authOverride.password !== undefined) {
authConfig.password = authOverride.password;
}
if (authOverride.allowTailscale !== undefined) {
authConfig.allowTailscale = authOverride.allowTailscale;
}
if (authOverride.rateLimit !== undefined) {
authConfig.rateLimit = authOverride.rateLimit;
}
if (authOverride.trustedProxy !== undefined) {
authConfig.trustedProxy = authOverride.trustedProxy;
}
}
const env = params.env ?? process.env;
const tokenRef = resolveSecretInputRef({ value: authConfig.token }).ref;
const passwordRef = resolveSecretInputRef({ value: authConfig.password }).ref;
const resolvedCredentials = resolveGatewayCredentialsFromValues({
configToken: tokenRef ? undefined : authConfig.token,
configPassword: passwordRef ? undefined : authConfig.password,
env,
tokenPrecedence: "config-first",
passwordPrecedence: "config-first", // pragma: allowlist secret
});
const token = resolvedCredentials.token;
const password = resolvedCredentials.password;
const trustedProxy = authConfig.trustedProxy;
let mode: ResolvedGatewayAuth["mode"];
let modeSource: ResolvedGatewayAuth["modeSource"];
if (authOverride?.mode !== undefined) {
mode = authOverride.mode;
modeSource = "override";
} else if (authConfig.mode) {
mode = authConfig.mode;
modeSource = "config";
} else if (password) {
mode = "password";
modeSource = "password";
} else if (token) {
mode = "token";
modeSource = "token";
} else {
mode = "token";
modeSource = "default";
}
const allowTailscale =
authConfig.allowTailscale ??
(params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy");
return {
mode,
modeSource,
token,
password,
allowTailscale,
trustedProxy,
};
}
export function resolveEffectiveSharedGatewayAuth(params: {
authConfig?: GatewayAuthConfig | null;
authOverride?: GatewayAuthConfig | null;
env?: NodeJS.ProcessEnv;
tailscaleMode?: GatewayTailscaleMode;
}): EffectiveSharedGatewayAuth | null {
const resolvedAuth = resolveGatewayAuth(params);
if (resolvedAuth.mode === "token") {
return {
mode: "token",
secret: resolvedAuth.token,
};
}
if (resolvedAuth.mode === "password") {
return {
mode: "password",
secret: resolvedAuth.password,
};
}
return null;
}
export function assertGatewayAuthConfigured(
auth: ResolvedGatewayAuth,
rawAuthConfig?: GatewayAuthConfig | null,

View File

@@ -0,0 +1,413 @@
import { isIP } from "node:net";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth-resolve.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
import type { SecurityAuditFinding } from "./audit.types.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js";
type CollectDangerousConfigFlags = (cfg: OpenClawConfig) => string[];
export type CollectGatewayConfigFindingsOptions = {
collectDangerousConfigFlags?: CollectDangerousConfigFlags;
};
function hasNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function collectCoreInsecureOrDangerousFlags(cfg: OpenClawConfig): string[] {
const enabledFlags: string[] = [];
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
enabledFlags.push("gateway.controlUi.allowInsecureAuth=true");
}
if (cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true) {
enabledFlags.push("gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true");
}
if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
enabledFlags.push("gateway.controlUi.dangerouslyDisableDeviceAuth=true");
}
if (cfg.hooks?.gmail?.allowUnsafeExternalContent === true) {
enabledFlags.push("hooks.gmail.allowUnsafeExternalContent=true");
}
if (Array.isArray(cfg.hooks?.mappings)) {
for (const [index, mapping] of cfg.hooks.mappings.entries()) {
if (mapping?.allowUnsafeExternalContent === true) {
enabledFlags.push(`hooks.mappings[${index}].allowUnsafeExternalContent=true`);
}
}
}
if (cfg.tools?.exec?.applyPatch?.workspaceOnly === false) {
enabledFlags.push("tools.exec.applyPatch.workspaceOnly=false");
}
return enabledFlags;
}
export function collectGatewayConfigFindings(
cfg: OpenClawConfig,
sourceConfig: OpenClawConfig,
env: NodeJS.ProcessEnv,
options: CollectGatewayConfigFindingsOptions = {},
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
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 controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const controlUiAllowedOrigins = (cfg.gateway?.controlUi?.allowedOrigins ?? [])
.map((value) => value.trim())
.filter(Boolean);
const dangerouslyAllowHostHeaderOriginFallback =
cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
: [];
const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0;
const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0;
const envTokenConfigured = hasNonEmptyString(env.OPENCLAW_GATEWAY_TOKEN);
const envPasswordConfigured = hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD);
const tokenConfiguredFromConfig = hasConfiguredSecretInput(
sourceConfig.gateway?.auth?.token,
sourceConfig.secrets?.defaults,
);
const passwordConfiguredFromConfig = hasConfiguredSecretInput(
sourceConfig.gateway?.auth?.password,
sourceConfig.secrets?.defaults,
);
const remoteTokenConfigured = hasConfiguredSecretInput(
sourceConfig.gateway?.remote?.token,
sourceConfig.secrets?.defaults,
);
const explicitAuthMode = sourceConfig.gateway?.auth?.mode;
const tokenCanWin =
hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured;
const passwordCanWin =
explicitAuthMode === "password" ||
(explicitAuthMode !== "token" &&
explicitAuthMode !== "none" &&
explicitAuthMode !== "trusted-proxy" &&
!tokenCanWin);
const tokenConfigured = tokenCanWin;
const passwordConfigured =
hasPassword || (passwordCanWin && (envPasswordConfigured || passwordConfiguredFromConfig));
const hasSharedSecret =
explicitAuthMode === "token"
? tokenConfigured
: explicitAuthMode === "password"
? passwordConfigured
: explicitAuthMode === "none" || explicitAuthMode === "trusted-proxy"
? false
: tokenConfigured || passwordConfigured;
const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve";
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
const allowRealIpFallback = cfg.gateway?.allowRealIpFallback === true;
const mdnsMode = cfg.discovery?.mdns?.mode ?? "minimal";
// HTTP /tools/invoke is intended for narrow automation, not session orchestration/admin operations.
// If operators opt-in to re-enabling these tools over HTTP, warn loudly so the choice is explicit.
const gatewayToolsAllowRaw = Array.isArray(cfg.gateway?.tools?.allow)
? cfg.gateway?.tools?.allow
: [];
const gatewayToolsAllow = new Set(
gatewayToolsAllowRaw.map((v) => normalizeOptionalLowercaseString(v) ?? "").filter(Boolean),
);
const reenabledOverHttp = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) =>
gatewayToolsAllow.has(name),
);
if (reenabledOverHttp.length > 0) {
const extraRisk = bind !== "loopback" || tailscaleMode === "funnel";
findings.push({
checkId: "gateway.tools_invoke_http.dangerous_allow",
severity: extraRisk ? "critical" : "warn",
title: "Gateway HTTP /tools/invoke re-enables dangerous tools",
detail:
`gateway.tools.allow includes ${reenabledOverHttp.join(", ")} which removes them from the default HTTP deny list. ` +
"This can allow remote session spawning / control-plane actions via HTTP and increases RCE blast radius if the gateway is reachable.",
remediation:
"Remove these entries from gateway.tools.allow (recommended). " +
"If you keep them enabled, keep gateway.bind loopback-only (or tailnet-only), restrict network exposure, and treat the gateway token/password as full-admin.",
});
}
if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") {
findings.push({
checkId: "gateway.bind_no_auth",
severity: "critical",
title: "Gateway binds beyond loopback without auth",
detail: `gateway.bind="${bind}" but no gateway.auth token/password is configured.`,
remediation: `Set gateway.auth (token recommended) or bind to loopback.`,
});
}
if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) {
findings.push({
checkId: "gateway.trusted_proxies_missing",
severity: "warn",
title: "Reverse proxy headers are not trusted",
detail:
"gateway.bind is loopback and gateway.trustedProxies is empty. " +
"If you expose the Control UI through a reverse proxy, configure trusted proxies " +
"so local-client checks cannot be spoofed.",
remediation:
"Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only.",
});
}
if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) {
findings.push({
checkId: "gateway.loopback_no_auth",
severity: "critical",
title: "Gateway auth missing on loopback",
detail:
"gateway.bind is loopback but no gateway auth secret is configured. " +
"If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.",
remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.",
});
}
if (
bind !== "loopback" &&
controlUiEnabled &&
controlUiAllowedOrigins.length === 0 &&
!dangerouslyAllowHostHeaderOriginFallback
) {
findings.push({
checkId: "gateway.control_ui.allowed_origins_required",
severity: "critical",
title: "Non-loopback Control UI missing explicit allowed origins",
detail:
"Control UI is enabled on a non-loopback bind but gateway.controlUi.allowedOrigins is empty. " +
"Strict origin policy requires explicit allowed origins for non-loopback deployments.",
remediation:
"Set gateway.controlUi.allowedOrigins to full trusted origins (for example https://control.example.com). " +
"If your deployment intentionally relies on Host-header origin fallback, set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true.",
});
}
if (controlUiAllowedOrigins.includes("*")) {
const exposed = bind !== "loopback";
findings.push({
checkId: "gateway.control_ui.allowed_origins_wildcard",
severity: exposed ? "critical" : "warn",
title: "Control UI allowed origins contains wildcard",
detail:
'gateway.controlUi.allowedOrigins includes "*" which means allow any browser origin for Control UI/WebChat requests. This disables origin allowlisting and should be treated as an intentional allow-all policy.',
remediation:
'Replace wildcard origins with explicit trusted origins (for example https://control.example.com). Do not use "*" outside tightly controlled local testing.',
});
}
if (dangerouslyAllowHostHeaderOriginFallback) {
const exposed = bind !== "loopback";
findings.push({
checkId: "gateway.control_ui.host_header_origin_fallback",
severity: exposed ? "critical" : "warn",
title: "DANGEROUS: Host-header origin fallback enabled",
detail:
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true enables Host-header origin fallback " +
"for Control UI/WebChat websocket checks and weakens DNS rebinding protections.",
remediation:
"Disable gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback and configure explicit gateway.controlUi.allowedOrigins.",
});
}
if (allowRealIpFallback) {
const hasNonLoopbackTrustedProxy = trustedProxies.some(
(proxy) => !isStrictLoopbackTrustedProxyEntry(proxy),
);
const exposed =
bind !== "loopback" || (auth.mode === "trusted-proxy" && hasNonLoopbackTrustedProxy);
findings.push({
checkId: "gateway.real_ip_fallback_enabled",
severity: exposed ? "critical" : "warn",
title: "X-Real-IP fallback is enabled",
detail:
"gateway.allowRealIpFallback=true trusts X-Real-IP when trusted proxies omit X-Forwarded-For. " +
"Misconfigured proxies that forward client-supplied X-Real-IP can spoof source IP and local-client checks.",
remediation:
"Keep gateway.allowRealIpFallback=false (default). Only enable this when your trusted proxy " +
"always overwrites X-Real-IP and cannot provide X-Forwarded-For.",
});
}
if (mdnsMode === "full") {
const exposed = bind !== "loopback";
findings.push({
checkId: "discovery.mdns_full_mode",
severity: exposed ? "critical" : "warn",
title: "mDNS full mode can leak host metadata",
detail:
'discovery.mdns.mode="full" publishes cliPath/sshPort in local-network TXT records. ' +
"This can reveal usernames, filesystem layout, and management ports.",
remediation:
'Prefer discovery.mdns.mode="minimal" (recommended) or "off", especially when gateway.bind is not loopback.',
});
}
if (tailscaleMode === "funnel") {
findings.push({
checkId: "gateway.tailscale_funnel",
severity: "critical",
title: "Tailscale Funnel exposure enabled",
detail: `gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing.`,
remediation: `Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off".`,
});
} else if (tailscaleMode === "serve") {
findings.push({
checkId: "gateway.tailscale_serve",
severity: "info",
title: "Tailscale Serve exposure enabled",
detail: `gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale).`,
});
}
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
findings.push({
checkId: "gateway.control_ui.insecure_auth",
severity: "warn",
title: "Control UI insecure auth toggle enabled",
detail:
"gateway.controlUi.allowInsecureAuth=true does not bypass secure context or device identity checks; only dangerouslyDisableDeviceAuth disables Control UI device identity checks.",
remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.",
});
}
if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
findings.push({
checkId: "gateway.control_ui.device_auth_disabled",
severity: "critical",
title: "DANGEROUS: Control UI device auth disabled",
detail:
"gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.",
remediation: "Disable it unless you are in a short-lived break-glass scenario.",
});
}
const enabledDangerousFlags = (
options.collectDangerousConfigFlags ?? collectCoreInsecureOrDangerousFlags
)(cfg);
if (enabledDangerousFlags.length > 0) {
findings.push({
checkId: "config.insecure_or_dangerous_flags",
severity: "warn",
title: "Insecure or dangerous config flags enabled",
detail: `Detected ${enabledDangerousFlags.length} enabled flag(s): ${enabledDangerousFlags.join(", ")}.`,
remediation:
"Disable these flags when not actively debugging, or keep deployment scoped to trusted/local-only networks.",
});
}
const token =
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
if (auth.mode === "token" && token && token.length < 24) {
findings.push({
checkId: "gateway.token_too_short",
severity: "warn",
title: "Gateway token looks short",
detail: `gateway auth token is ${token.length} chars; prefer a long random token.`,
});
}
if (auth.mode === "trusted-proxy") {
const trustedProxies = cfg.gateway?.trustedProxies ?? [];
const trustedProxyConfig = cfg.gateway?.auth?.trustedProxy;
findings.push({
checkId: "gateway.trusted_proxy_auth",
severity: "critical",
title: "Trusted-proxy auth mode enabled",
detail:
'gateway.auth.mode="trusted-proxy" delegates authentication to a reverse proxy. ' +
"Ensure your proxy (Pomerium, Caddy, nginx) handles auth correctly and that gateway.trustedProxies " +
"only contains IPs of your actual proxy servers.",
remediation:
"Verify: (1) Your proxy terminates TLS and authenticates users. " +
"(2) gateway.trustedProxies is restricted to proxy IPs only. " +
"(3) Direct access to the Gateway port is blocked by firewall. " +
"See /gateway/trusted-proxy-auth for setup guidance.",
});
if (trustedProxies.length === 0) {
findings.push({
checkId: "gateway.trusted_proxy_no_proxies",
severity: "critical",
title: "Trusted-proxy auth enabled but no trusted proxies configured",
detail:
'gateway.auth.mode="trusted-proxy" but gateway.trustedProxies is empty. ' +
"All requests will be rejected.",
remediation: "Set gateway.trustedProxies to the IP(s) of your reverse proxy.",
});
}
if (!trustedProxyConfig?.userHeader) {
findings.push({
checkId: "gateway.trusted_proxy_no_user_header",
severity: "critical",
title: "Trusted-proxy auth missing userHeader config",
detail:
'gateway.auth.mode="trusted-proxy" but gateway.auth.trustedProxy.userHeader is not configured.',
remediation:
"Set gateway.auth.trustedProxy.userHeader to the header name your proxy uses " +
'(e.g., "x-forwarded-user", "x-pomerium-claim-email").',
});
}
const allowUsers = trustedProxyConfig?.allowUsers ?? [];
if (allowUsers.length === 0) {
findings.push({
checkId: "gateway.trusted_proxy_no_allowlist",
severity: "warn",
title: "Trusted-proxy auth allows all authenticated users",
detail:
"gateway.auth.trustedProxy.allowUsers is empty, so any user authenticated by your proxy can access the Gateway.",
remediation:
"Consider setting gateway.auth.trustedProxy.allowUsers to restrict access to specific users " +
'(e.g., ["nick@example.com"]).',
});
}
}
if (bind !== "loopback" && auth.mode !== "trusted-proxy" && !cfg.gateway?.auth?.rateLimit) {
findings.push({
checkId: "gateway.auth_no_rate_limit",
severity: "warn",
title: "No auth rate limiting configured",
detail:
"gateway.bind is not loopback but no gateway.auth.rateLimit is configured. " +
"Without rate limiting, brute-force auth attacks are not mitigated.",
remediation:
"Set gateway.auth.rateLimit (e.g. { maxAttempts: 10, windowMs: 60000, lockoutMs: 300000 }).",
});
}
return findings;
}
// Keep this stricter than isLoopbackAddress on purpose: this check is for
// trust boundaries, so only explicit localhost proxy hops are treated as local.
function isStrictLoopbackTrustedProxyEntry(entry: string): boolean {
const candidate = entry.trim();
if (!candidate) {
return false;
}
if (!candidate.includes("/")) {
return candidate === "127.0.0.1" || candidate.toLowerCase() === "::1";
}
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) {
return rawIp.trim() === "127.0.0.1" && prefix === 32;
}
if (ipVersion === 6) {
return prefix === 128 && normalizeLowercaseStringOrEmpty(rawIp) === "::1";
}
return false;
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectGatewayConfigFindings } from "./audit.js";
import { collectGatewayConfigFindings } from "./audit-gateway-config.js";
function hasFinding(
checkId: string,
@@ -60,22 +60,6 @@ describe("security audit gateway exposure findings", () => {
"tools.exec.applyPatch.workspaceOnly=false",
],
},
{
name: "acpx approve-all is treated as a dangerous break-glass flag",
cfg: {
plugins: {
entries: {
acpx: {
enabled: true,
config: {
permissionMode: "approve-all",
},
},
},
},
} satisfies OpenClawConfig,
expectedDangerousDetails: ["plugins.entries.acpx.config.permissionMode=approve-all"],
},
] as const;
for (const testCase of cases) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { collectGatewayConfigFindings } from "./audit.js";
import { collectGatewayConfigFindings } from "./audit-gateway-config.js";
function hasFinding(
findings: ReturnType<typeof collectGatewayConfigFindings>,

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { withEnvAsync } from "../test-utils/env.js";
import { collectGatewayConfigFindings } from "./audit.js";
import { collectGatewayConfigFindings } from "./audit-gateway-config.js";
function hasFinding(checkId: string, findings: ReturnType<typeof collectGatewayConfigFindings>) {
return findings.some((finding) => finding.checkId === checkId);

View File

@@ -1,4 +1,3 @@
import { isIP } from "node:net";
import path from "node:path";
import { resolveSandboxConfigForAgent } from "../agents/sandbox/config.js";
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
@@ -6,8 +5,6 @@ import type { listChannelPlugins } from "../channels/plugins/index.js";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js";
import { resolveConfigPath, resolveStateDir } from "../config/paths.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import { hasConfiguredSecretInput } from "../config/types.secrets.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { type ExecApprovalsFile, loadExecApprovals } from "../infra/exec-approvals.js";
import { isInterpreterLikeAllowlistPattern } from "../infra/exec-inline-eval.js";
import {
@@ -16,14 +13,10 @@ import {
} from "../infra/exec-safe-bin-runtime-policy.js";
import { listRiskyConfiguredSafeBins } from "../infra/exec-safe-bin-semantics.js";
import { normalizeTrustedSafeBinDirs } from "../infra/exec-safe-bin-trust.js";
import { hasNonEmptyString } from "../infra/outbound/channel-target.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
import { asNullableRecord } from "../shared/record-coerce.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "../shared/string-coerce.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { collectDeepCodeSafetyFindings } from "./audit-deep-code-safety.js";
import { collectDeepProbeFindings } from "./audit-deep-probe-findings.js";
import {
@@ -31,6 +24,7 @@ import {
formatPermissionRemediation,
inspectPathPermissions,
} from "./audit-fs.js";
import { collectGatewayConfigFindings as collectGatewayConfigFindingsBase } from "./audit-gateway-config.js";
import type {
SecurityAuditFinding,
SecurityAuditReport,
@@ -38,7 +32,6 @@ import type {
SecurityAuditSummary,
} from "./audit.types.js";
import { collectEnabledInsecureOrDangerousFlags } from "./dangerous-config-flags.js";
import { DEFAULT_GATEWAY_HTTP_TOOL_DENY } from "./dangerous-tools.js";
import type { ExecFn } from "./windows-acl.js";
type ExecDockerRawFn = typeof import("../agents/sandbox/docker.js").execDockerRaw;
@@ -320,362 +313,9 @@ export function collectGatewayConfigFindings(
sourceConfig: OpenClawConfig,
env: NodeJS.ProcessEnv,
): SecurityAuditFinding[] {
const findings: SecurityAuditFinding[] = [];
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 controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false;
const controlUiAllowedOrigins = (cfg.gateway?.controlUi?.allowedOrigins ?? [])
.map((value) => value.trim())
.filter(Boolean);
const dangerouslyAllowHostHeaderOriginFallback =
cfg.gateway?.controlUi?.dangerouslyAllowHostHeaderOriginFallback === true;
const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies)
? cfg.gateway.trustedProxies
: [];
const hasToken = typeof auth.token === "string" && auth.token.trim().length > 0;
const hasPassword = typeof auth.password === "string" && auth.password.trim().length > 0;
const envTokenConfigured = hasNonEmptyString(env.OPENCLAW_GATEWAY_TOKEN);
const envPasswordConfigured = hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD);
const tokenConfiguredFromConfig = hasConfiguredSecretInput(
sourceConfig.gateway?.auth?.token,
sourceConfig.secrets?.defaults,
);
const passwordConfiguredFromConfig = hasConfiguredSecretInput(
sourceConfig.gateway?.auth?.password,
sourceConfig.secrets?.defaults,
);
const remoteTokenConfigured = hasConfiguredSecretInput(
sourceConfig.gateway?.remote?.token,
sourceConfig.secrets?.defaults,
);
const explicitAuthMode = sourceConfig.gateway?.auth?.mode;
const tokenCanWin =
hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured;
const passwordCanWin =
explicitAuthMode === "password" ||
(explicitAuthMode !== "token" &&
explicitAuthMode !== "none" &&
explicitAuthMode !== "trusted-proxy" &&
!tokenCanWin);
const tokenConfigured = tokenCanWin;
const passwordConfigured =
hasPassword || (passwordCanWin && (envPasswordConfigured || passwordConfiguredFromConfig));
const hasSharedSecret =
explicitAuthMode === "token"
? tokenConfigured
: explicitAuthMode === "password"
? passwordConfigured
: explicitAuthMode === "none" || explicitAuthMode === "trusted-proxy"
? false
: tokenConfigured || passwordConfigured;
const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve";
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
const allowRealIpFallback = cfg.gateway?.allowRealIpFallback === true;
const mdnsMode = cfg.discovery?.mdns?.mode ?? "minimal";
// HTTP /tools/invoke is intended for narrow automation, not session orchestration/admin operations.
// If operators opt-in to re-enabling these tools over HTTP, warn loudly so the choice is explicit.
const gatewayToolsAllowRaw = Array.isArray(cfg.gateway?.tools?.allow)
? cfg.gateway?.tools?.allow
: [];
const gatewayToolsAllow = new Set(
gatewayToolsAllowRaw.map((v) => normalizeOptionalLowercaseString(v) ?? "").filter(Boolean),
);
const reenabledOverHttp = DEFAULT_GATEWAY_HTTP_TOOL_DENY.filter((name) =>
gatewayToolsAllow.has(name),
);
if (reenabledOverHttp.length > 0) {
const extraRisk = bind !== "loopback" || tailscaleMode === "funnel";
findings.push({
checkId: "gateway.tools_invoke_http.dangerous_allow",
severity: extraRisk ? "critical" : "warn",
title: "Gateway HTTP /tools/invoke re-enables dangerous tools",
detail:
`gateway.tools.allow includes ${reenabledOverHttp.join(", ")} which removes them from the default HTTP deny list. ` +
"This can allow remote session spawning / control-plane actions via HTTP and increases RCE blast radius if the gateway is reachable.",
remediation:
"Remove these entries from gateway.tools.allow (recommended). " +
"If you keep them enabled, keep gateway.bind loopback-only (or tailnet-only), restrict network exposure, and treat the gateway token/password as full-admin.",
});
}
if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") {
findings.push({
checkId: "gateway.bind_no_auth",
severity: "critical",
title: "Gateway binds beyond loopback without auth",
detail: `gateway.bind="${bind}" but no gateway.auth token/password is configured.`,
remediation: `Set gateway.auth (token recommended) or bind to loopback.`,
});
}
if (bind === "loopback" && controlUiEnabled && trustedProxies.length === 0) {
findings.push({
checkId: "gateway.trusted_proxies_missing",
severity: "warn",
title: "Reverse proxy headers are not trusted",
detail:
"gateway.bind is loopback and gateway.trustedProxies is empty. " +
"If you expose the Control UI through a reverse proxy, configure trusted proxies " +
"so local-client checks cannot be spoofed.",
remediation:
"Set gateway.trustedProxies to your proxy IPs or keep the Control UI local-only.",
});
}
if (bind === "loopback" && controlUiEnabled && !hasGatewayAuth) {
findings.push({
checkId: "gateway.loopback_no_auth",
severity: "critical",
title: "Gateway auth missing on loopback",
detail:
"gateway.bind is loopback but no gateway auth secret is configured. " +
"If the Control UI is exposed through a reverse proxy, unauthenticated access is possible.",
remediation: "Set gateway.auth (token recommended) or keep the Control UI local-only.",
});
}
if (
bind !== "loopback" &&
controlUiEnabled &&
controlUiAllowedOrigins.length === 0 &&
!dangerouslyAllowHostHeaderOriginFallback
) {
findings.push({
checkId: "gateway.control_ui.allowed_origins_required",
severity: "critical",
title: "Non-loopback Control UI missing explicit allowed origins",
detail:
"Control UI is enabled on a non-loopback bind but gateway.controlUi.allowedOrigins is empty. " +
"Strict origin policy requires explicit allowed origins for non-loopback deployments.",
remediation:
"Set gateway.controlUi.allowedOrigins to full trusted origins (for example https://control.example.com). " +
"If your deployment intentionally relies on Host-header origin fallback, set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true.",
});
}
if (controlUiAllowedOrigins.includes("*")) {
const exposed = bind !== "loopback";
findings.push({
checkId: "gateway.control_ui.allowed_origins_wildcard",
severity: exposed ? "critical" : "warn",
title: "Control UI allowed origins contains wildcard",
detail:
'gateway.controlUi.allowedOrigins includes "*" which means allow any browser origin for Control UI/WebChat requests. This disables origin allowlisting and should be treated as an intentional allow-all policy.',
remediation:
'Replace wildcard origins with explicit trusted origins (for example https://control.example.com). Do not use "*" outside tightly controlled local testing.',
});
}
if (dangerouslyAllowHostHeaderOriginFallback) {
const exposed = bind !== "loopback";
findings.push({
checkId: "gateway.control_ui.host_header_origin_fallback",
severity: exposed ? "critical" : "warn",
title: "DANGEROUS: Host-header origin fallback enabled",
detail:
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true enables Host-header origin fallback " +
"for Control UI/WebChat websocket checks and weakens DNS rebinding protections.",
remediation:
"Disable gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback and configure explicit gateway.controlUi.allowedOrigins.",
});
}
if (allowRealIpFallback) {
const hasNonLoopbackTrustedProxy = trustedProxies.some(
(proxy) => !isStrictLoopbackTrustedProxyEntry(proxy),
);
const exposed =
bind !== "loopback" || (auth.mode === "trusted-proxy" && hasNonLoopbackTrustedProxy);
findings.push({
checkId: "gateway.real_ip_fallback_enabled",
severity: exposed ? "critical" : "warn",
title: "X-Real-IP fallback is enabled",
detail:
"gateway.allowRealIpFallback=true trusts X-Real-IP when trusted proxies omit X-Forwarded-For. " +
"Misconfigured proxies that forward client-supplied X-Real-IP can spoof source IP and local-client checks.",
remediation:
"Keep gateway.allowRealIpFallback=false (default). Only enable this when your trusted proxy " +
"always overwrites X-Real-IP and cannot provide X-Forwarded-For.",
});
}
if (mdnsMode === "full") {
const exposed = bind !== "loopback";
findings.push({
checkId: "discovery.mdns_full_mode",
severity: exposed ? "critical" : "warn",
title: "mDNS full mode can leak host metadata",
detail:
'discovery.mdns.mode="full" publishes cliPath/sshPort in local-network TXT records. ' +
"This can reveal usernames, filesystem layout, and management ports.",
remediation:
'Prefer discovery.mdns.mode="minimal" (recommended) or "off", especially when gateway.bind is not loopback.',
});
}
if (tailscaleMode === "funnel") {
findings.push({
checkId: "gateway.tailscale_funnel",
severity: "critical",
title: "Tailscale Funnel exposure enabled",
detail: `gateway.tailscale.mode="funnel" exposes the Gateway publicly; keep auth strict and treat it as internet-facing.`,
remediation: `Prefer tailscale.mode="serve" (tailnet-only) or set tailscale.mode="off".`,
});
} else if (tailscaleMode === "serve") {
findings.push({
checkId: "gateway.tailscale_serve",
severity: "info",
title: "Tailscale Serve exposure enabled",
detail: `gateway.tailscale.mode="serve" exposes the Gateway to your tailnet (loopback behind Tailscale).`,
});
}
if (cfg.gateway?.controlUi?.allowInsecureAuth === true) {
findings.push({
checkId: "gateway.control_ui.insecure_auth",
severity: "warn",
title: "Control UI insecure auth toggle enabled",
detail:
"gateway.controlUi.allowInsecureAuth=true does not bypass secure context or device identity checks; only dangerouslyDisableDeviceAuth disables Control UI device identity checks.",
remediation: "Disable it or switch to HTTPS (Tailscale Serve) or localhost.",
});
}
if (cfg.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true) {
findings.push({
checkId: "gateway.control_ui.device_auth_disabled",
severity: "critical",
title: "DANGEROUS: Control UI device auth disabled",
detail:
"gateway.controlUi.dangerouslyDisableDeviceAuth=true disables device identity checks for the Control UI.",
remediation: "Disable it unless you are in a short-lived break-glass scenario.",
});
}
const enabledDangerousFlags = collectEnabledInsecureOrDangerousFlags(cfg);
if (enabledDangerousFlags.length > 0) {
findings.push({
checkId: "config.insecure_or_dangerous_flags",
severity: "warn",
title: "Insecure or dangerous config flags enabled",
detail: `Detected ${enabledDangerousFlags.length} enabled flag(s): ${enabledDangerousFlags.join(", ")}.`,
remediation:
"Disable these flags when not actively debugging, or keep deployment scoped to trusted/local-only networks.",
});
}
const token =
typeof auth.token === "string" && auth.token.trim().length > 0 ? auth.token.trim() : null;
if (auth.mode === "token" && token && token.length < 24) {
findings.push({
checkId: "gateway.token_too_short",
severity: "warn",
title: "Gateway token looks short",
detail: `gateway auth token is ${token.length} chars; prefer a long random token.`,
});
}
if (auth.mode === "trusted-proxy") {
const trustedProxies = cfg.gateway?.trustedProxies ?? [];
const trustedProxyConfig = cfg.gateway?.auth?.trustedProxy;
findings.push({
checkId: "gateway.trusted_proxy_auth",
severity: "critical",
title: "Trusted-proxy auth mode enabled",
detail:
'gateway.auth.mode="trusted-proxy" delegates authentication to a reverse proxy. ' +
"Ensure your proxy (Pomerium, Caddy, nginx) handles auth correctly and that gateway.trustedProxies " +
"only contains IPs of your actual proxy servers.",
remediation:
"Verify: (1) Your proxy terminates TLS and authenticates users. " +
"(2) gateway.trustedProxies is restricted to proxy IPs only. " +
"(3) Direct access to the Gateway port is blocked by firewall. " +
"See /gateway/trusted-proxy-auth for setup guidance.",
});
if (trustedProxies.length === 0) {
findings.push({
checkId: "gateway.trusted_proxy_no_proxies",
severity: "critical",
title: "Trusted-proxy auth enabled but no trusted proxies configured",
detail:
'gateway.auth.mode="trusted-proxy" but gateway.trustedProxies is empty. ' +
"All requests will be rejected.",
remediation: "Set gateway.trustedProxies to the IP(s) of your reverse proxy.",
});
}
if (!trustedProxyConfig?.userHeader) {
findings.push({
checkId: "gateway.trusted_proxy_no_user_header",
severity: "critical",
title: "Trusted-proxy auth missing userHeader config",
detail:
'gateway.auth.mode="trusted-proxy" but gateway.auth.trustedProxy.userHeader is not configured.',
remediation:
"Set gateway.auth.trustedProxy.userHeader to the header name your proxy uses " +
'(e.g., "x-forwarded-user", "x-pomerium-claim-email").',
});
}
const allowUsers = trustedProxyConfig?.allowUsers ?? [];
if (allowUsers.length === 0) {
findings.push({
checkId: "gateway.trusted_proxy_no_allowlist",
severity: "warn",
title: "Trusted-proxy auth allows all authenticated users",
detail:
"gateway.auth.trustedProxy.allowUsers is empty, so any user authenticated by your proxy can access the Gateway.",
remediation:
"Consider setting gateway.auth.trustedProxy.allowUsers to restrict access to specific users " +
'(e.g., ["nick@example.com"]).',
});
}
}
if (bind !== "loopback" && auth.mode !== "trusted-proxy" && !cfg.gateway?.auth?.rateLimit) {
findings.push({
checkId: "gateway.auth_no_rate_limit",
severity: "warn",
title: "No auth rate limiting configured",
detail:
"gateway.bind is not loopback but no gateway.auth.rateLimit is configured. " +
"Without rate limiting, brute-force auth attacks are not mitigated.",
remediation:
"Set gateway.auth.rateLimit (e.g. { maxAttempts: 10, windowMs: 60000, lockoutMs: 300000 }).",
});
}
return findings;
}
// Keep this stricter than isLoopbackAddress on purpose: this check is for
// trust boundaries, so only explicit localhost proxy hops are treated as local.
function isStrictLoopbackTrustedProxyEntry(entry: string): boolean {
const candidate = entry.trim();
if (!candidate) {
return false;
}
if (!candidate.includes("/")) {
return candidate === "127.0.0.1" || candidate.toLowerCase() === "::1";
}
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) {
return rawIp.trim() === "127.0.0.1" && prefix === 32;
}
if (ipVersion === 6) {
return prefix === 128 && normalizeLowercaseStringOrEmpty(rawIp) === "::1";
}
return false;
return collectGatewayConfigFindingsBase(cfg, sourceConfig, env, {
collectDangerousConfigFlags: collectEnabledInsecureOrDangerousFlags,
});
}
async function collectPluginSecurityAuditFindings(