mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
perf(test): isolate gateway audit tests
This commit is contained in:
127
src/gateway/auth-resolve.ts
Normal file
127
src/gateway/auth-resolve.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
413
src/security/audit-gateway-config.ts
Normal file
413
src/security/audit-gateway-config.ts
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user