feat(gateway): add trusted-proxy auth mode (#15940)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 279d4b304f83186fda44dfe63a729406a835dafa Co-authored-by: nickytonline <833231+nickytonline@users.noreply.github.com> Co-authored-by: steipete <58493+steipete@users.noreply.github.com> Reviewed-by: @steipete
This commit is contained in:
@@ -449,7 +449,10 @@ export function collectSecretsInConfigFindings(cfg: OpenClawConfig): SecurityAud
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
export function collectHooksHardeningFindings(
|
||||
cfg: OpenClawConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
if (cfg.hooks?.enabled !== true) {
|
||||
return findings;
|
||||
@@ -468,13 +471,20 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi
|
||||
const gatewayAuth = resolveGatewayAuth({
|
||||
authConfig: cfg.gateway?.auth,
|
||||
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
|
||||
env,
|
||||
});
|
||||
const openclawGatewayToken =
|
||||
typeof env.OPENCLAW_GATEWAY_TOKEN === "string" && env.OPENCLAW_GATEWAY_TOKEN.trim()
|
||||
? env.OPENCLAW_GATEWAY_TOKEN.trim()
|
||||
: null;
|
||||
const gatewayToken =
|
||||
gatewayAuth.mode === "token" &&
|
||||
typeof gatewayAuth.token === "string" &&
|
||||
gatewayAuth.token.trim()
|
||||
? gatewayAuth.token.trim()
|
||||
: null;
|
||||
: openclawGatewayToken
|
||||
? openclawGatewayToken
|
||||
: null;
|
||||
if (token && gatewayToken && token === gatewayToken) {
|
||||
findings.push({
|
||||
checkId: "hooks.token_reuse_gateway_token",
|
||||
@@ -545,6 +555,33 @@ export function collectHooksHardeningFindings(cfg: OpenClawConfig): SecurityAudi
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectGatewayHttpSessionKeyOverrideFindings(
|
||||
cfg: OpenClawConfig,
|
||||
): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true;
|
||||
const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true;
|
||||
if (!chatCompletionsEnabled && !responsesEnabled) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const enabledEndpoints = [
|
||||
chatCompletionsEnabled ? "/v1/chat/completions" : null,
|
||||
responsesEnabled ? "/v1/responses" : null,
|
||||
].filter((entry): entry is string => Boolean(entry));
|
||||
|
||||
findings.push({
|
||||
checkId: "gateway.http.session_key_override_enabled",
|
||||
severity: "info",
|
||||
title: "HTTP API session-key override is enabled",
|
||||
detail:
|
||||
`${enabledEndpoints.join(", ")} accept x-openclaw-session-key for per-request session routing. ` +
|
||||
"Treat API credential holders as trusted principals.",
|
||||
});
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
export function collectSandboxDockerNoopFindings(cfg: OpenClawConfig): SecurityAuditFinding[] {
|
||||
const findings: SecurityAuditFinding[] = [];
|
||||
const configuredPaths: string[] = [];
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
export {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
collectExposureMatrixFindings,
|
||||
collectGatewayHttpSessionKeyOverrideFindings,
|
||||
collectHooksHardeningFindings,
|
||||
collectMinimalProfileOverrideFindings,
|
||||
collectModelHygieneFindings,
|
||||
|
||||
@@ -95,23 +95,42 @@ describe("security audit", () => {
|
||||
});
|
||||
|
||||
it("flags non-loopback bind without auth as critical", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {},
|
||||
},
|
||||
};
|
||||
// Clear env tokens so resolveGatewayAuth defaults to mode=none
|
||||
const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
const prevPassword = process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
env: {},
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
auth: {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
res.findings.some((f) => f.checkId === "gateway.bind_no_auth" && f.severity === "critical"),
|
||||
).toBe(true);
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
res.findings.some((f) => f.checkId === "gateway.bind_no_auth" && f.severity === "critical"),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
// Restore env
|
||||
if (prevToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
|
||||
}
|
||||
if (prevPassword === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_PASSWORD = prevPassword;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when non-loopback bind has auth but no auth rate limit", async () => {
|
||||
@@ -593,6 +612,127 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("flags trusted-proxy auth mode without generic shared-secret findings", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.trusted_proxy_auth",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(res.findings.some((f) => f.checkId === "gateway.bind_no_auth")).toBe(false);
|
||||
expect(res.findings.some((f) => f.checkId === "gateway.auth_no_rate_limit")).toBe(false);
|
||||
});
|
||||
|
||||
it("flags trusted-proxy auth without trustedProxies configured", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
trustedProxies: [],
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.trusted_proxy_no_proxies",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags trusted-proxy auth without userHeader configured", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {} as never,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.trusted_proxy_no_user_header",
|
||||
severity: "critical",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when trusted-proxy auth allows all users", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
allowUsers: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
});
|
||||
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "gateway.trusted_proxy_no_allowlist",
|
||||
severity: "warn",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when multiple DM senders share the main session", async () => {
|
||||
const cfg: OpenClawConfig = { session: { dmScope: "main" } };
|
||||
const plugins: ChannelPlugin[] = [
|
||||
|
||||
@@ -12,6 +12,7 @@ import { collectChannelSecurityFindings } from "./audit-channel.js";
|
||||
import {
|
||||
collectAttackSurfaceSummaryFindings,
|
||||
collectExposureMatrixFindings,
|
||||
collectGatewayHttpSessionKeyOverrideFindings,
|
||||
collectHooksHardeningFindings,
|
||||
collectIncludeFilePermFindings,
|
||||
collectInstalledSkillsCodeSafetyFindings,
|
||||
@@ -257,10 +258,7 @@ function collectGatewayConfigFindings(
|
||||
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
|
||||
const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve";
|
||||
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
|
||||
const remotelyExposed =
|
||||
bind !== "loopback" || tailscaleMode === "serve" || tailscaleMode === "funnel";
|
||||
|
||||
if (bind !== "loopback" && !hasSharedSecret) {
|
||||
if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") {
|
||||
findings.push({
|
||||
checkId: "gateway.bind_no_auth",
|
||||
severity: "critical",
|
||||
@@ -346,26 +344,66 @@ function collectGatewayConfigFindings(
|
||||
});
|
||||
}
|
||||
|
||||
const chatCompletionsEnabled = cfg.gateway?.http?.endpoints?.chatCompletions?.enabled === true;
|
||||
const responsesEnabled = cfg.gateway?.http?.endpoints?.responses?.enabled === true;
|
||||
if (chatCompletionsEnabled || responsesEnabled) {
|
||||
const enabledEndpoints = [
|
||||
chatCompletionsEnabled ? "/v1/chat/completions" : null,
|
||||
responsesEnabled ? "/v1/responses" : null,
|
||||
].filter((value): value is string => Boolean(value));
|
||||
if (auth.mode === "trusted-proxy") {
|
||||
const trustedProxies = cfg.gateway?.trustedProxies ?? [];
|
||||
const trustedProxyConfig = cfg.gateway?.auth?.trustedProxy;
|
||||
|
||||
findings.push({
|
||||
checkId: "gateway.http.session_key_override_enabled",
|
||||
severity: remotelyExposed ? "warn" : "info",
|
||||
title: "HTTP APIs accept explicit session key override headers",
|
||||
checkId: "gateway.trusted_proxy_auth",
|
||||
severity: "critical",
|
||||
title: "Trusted-proxy auth mode enabled",
|
||||
detail:
|
||||
`${enabledEndpoints.join(", ")} support x-openclaw-session-key. ` +
|
||||
"Any authenticated caller can route requests into arbitrary sessions.",
|
||||
'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:
|
||||
"Treat HTTP API credentials as full-trust, disable unused endpoints, and avoid sharing tokens across tenants.",
|
||||
"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" && !cfg.gateway?.auth?.rateLimit) {
|
||||
if (bind !== "loopback" && auth.mode !== "trusted-proxy" && !cfg.gateway?.auth?.rateLimit) {
|
||||
findings.push({
|
||||
checkId: "gateway.auth_no_rate_limit",
|
||||
severity: "warn",
|
||||
@@ -570,7 +608,8 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise<Secu
|
||||
findings.push(...collectBrowserControlFindings(cfg, env));
|
||||
findings.push(...collectLoggingFindings(cfg));
|
||||
findings.push(...collectElevatedFindings(cfg));
|
||||
findings.push(...collectHooksHardeningFindings(cfg));
|
||||
findings.push(...collectHooksHardeningFindings(cfg, env));
|
||||
findings.push(...collectGatewayHttpSessionKeyOverrideFindings(cfg));
|
||||
findings.push(...collectSandboxDockerNoopFindings(cfg));
|
||||
findings.push(...collectNodeDenyCommandPatternFindings(cfg));
|
||||
findings.push(...collectMinimalProfileOverrideFindings(cfg));
|
||||
|
||||
Reference in New Issue
Block a user