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:
Nick Taylor
2026-02-14 06:32:17 -05:00
committed by GitHub
parent 3a330e681b
commit 1fb52b4d7b
28 changed files with 1867 additions and 92 deletions

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
describe("ensureBrowserControlAuth", () => {
describe("trusted-proxy mode", () => {
it("should not auto-generate token when auth mode is trusted-proxy", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
trustedProxies: ["192.168.1.1"],
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBeUndefined();
expect(result.auth.password).toBeUndefined();
});
});
describe("password mode", () => {
it("should not auto-generate token when auth mode is password (even if password not set)", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "password",
},
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBeUndefined();
expect(result.auth.password).toBeUndefined();
});
});
describe("token mode", () => {
it("should return existing token if configured", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
token: "existing-token-123",
},
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { OPENCLAW_BROWSER_AUTO_AUTH: "1" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBe("existing-token-123");
});
it("should skip auto-generation in test environment", async () => {
const cfg: OpenClawConfig = {
gateway: {
auth: {
mode: "token",
},
},
};
const result = await ensureBrowserControlAuth({
cfg,
env: { NODE_ENV: "test" },
});
expect(result.generatedToken).toBeUndefined();
expect(result.auth.token).toBeUndefined();
});
});
});

View File

@@ -58,6 +58,10 @@ export async function ensureBrowserControlAuth(params: {
return { auth };
}
if (params.cfg.gateway?.auth?.mode === "trusted-proxy") {
return { auth };
}
// Re-read latest config to avoid racing with concurrent config writers.
const latestCfg = loadConfig();
const latestAuth = resolveBrowserControlAuth(latestCfg, env);
@@ -67,6 +71,9 @@ export async function ensureBrowserControlAuth(params: {
if (latestCfg.gateway?.auth?.mode === "password") {
return { auth: latestAuth };
}
if (latestCfg.gateway?.auth?.mode === "trusted-proxy") {
return { auth: latestAuth };
}
const generatedToken = crypto.randomBytes(24).toString("hex");
const nextCfg: OpenClawConfig = {

View File

@@ -247,7 +247,7 @@ async function runGatewayCommand(opts: GatewayRunOpts) {
defaultRuntime.exit(1);
return;
}
if (bind !== "loopback" && !hasSharedSecret) {
if (bind !== "loopback" && !hasSharedSecret && resolvedAuthMode !== "trusted-proxy") {
defaultRuntime.error(
[
`Refusing to bind gateway to ${bind} without auth.`,

View File

@@ -117,4 +117,94 @@ describe("buildGatewayAuthConfig", () => {
expect(typeof result?.token).toBe("string");
expect(result?.token?.length).toBeGreaterThan(0);
});
it("builds trusted-proxy config with all options", () => {
const result = buildGatewayAuthConfig({
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
allowUsers: ["nick@example.com", "admin@company.com"],
},
});
expect(result).toEqual({
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
allowUsers: ["nick@example.com", "admin@company.com"],
},
});
});
it("builds trusted-proxy config with only userHeader", () => {
const result = buildGatewayAuthConfig({
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-remote-user",
},
});
expect(result).toEqual({
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-remote-user",
},
});
});
it("preserves allowTailscale when switching to trusted-proxy", () => {
const result = buildGatewayAuthConfig({
existing: {
mode: "token",
token: "abc",
allowTailscale: true,
},
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
});
expect(result).toEqual({
mode: "trusted-proxy",
allowTailscale: true,
trustedProxy: {
userHeader: "x-forwarded-user",
},
});
});
it("throws error when trusted-proxy mode lacks trustedProxy config", () => {
expect(() => {
buildGatewayAuthConfig({
mode: "trusted-proxy",
// missing trustedProxy
});
}).toThrow("trustedProxy config is required when mode is trusted-proxy");
});
it("drops token and password when switching to trusted-proxy", () => {
const result = buildGatewayAuthConfig({
existing: {
mode: "token",
token: "abc",
password: "secret",
},
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
});
expect(result).toEqual({
mode: "trusted-proxy",
trustedProxy: {
userHeader: "x-forwarded-user",
},
});
expect(result).not.toHaveProperty("token");
expect(result).not.toHaveProperty("password");
});
});

View File

@@ -14,7 +14,7 @@ import {
import { promptCustomApiConfig } from "./onboard-custom.js";
import { randomToken } from "./onboard-helpers.js";
type GatewayAuthChoice = "token" | "password";
type GatewayAuthChoice = "token" | "password" | "trusted-proxy";
/** Reject undefined, empty, and common JS string-coercion artifacts for token auth. */
function sanitizeTokenValue(value: string | undefined): string | undefined {
@@ -40,6 +40,11 @@ export function buildGatewayAuthConfig(params: {
mode: GatewayAuthChoice;
token?: string;
password?: string;
trustedProxy?: {
userHeader: string;
requiredHeaders?: string[];
allowUsers?: string[];
};
}): GatewayAuthConfig | undefined {
const allowTailscale = params.existing?.allowTailscale;
const base: GatewayAuthConfig = {};
@@ -52,8 +57,17 @@ export function buildGatewayAuthConfig(params: {
const token = sanitizeTokenValue(params.token) ?? randomToken();
return { ...base, mode: "token", token };
}
const password = params.password?.trim();
return { ...base, mode: "password", ...(password && { password }) };
if (params.mode === "password") {
const password = params.password?.trim();
return { ...base, mode: "password", ...(password && { password }) };
}
if (params.mode === "trusted-proxy") {
if (!params.trustedProxy) {
throw new Error("trustedProxy config is required when mode is trusted-proxy");
}
return { ...base, mode: "trusted-proxy", trustedProxy: params.trustedProxy };
}
return base;
}
export async function promptAuthConfig(

View File

@@ -97,4 +97,99 @@ describe("promptGatewayConfig", () => {
expect(call?.password).not.toBe("undefined");
expect(call?.password).toBe("");
});
it("prompts for trusted-proxy configuration when trusted-proxy mode selected", async () => {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
// Flow: loopback bind → trusted-proxy auth → tailscale off
const selectQueue = ["loopback", "trusted-proxy", "off"];
mocks.select.mockImplementation(async () => selectQueue.shift());
// Port prompt, userHeader, requiredHeaders, allowUsers, trustedProxies
const textQueue = [
"18789",
"x-forwarded-user",
"x-forwarded-proto,x-forwarded-host",
"nick@example.com",
"10.0.1.10,192.168.1.5",
];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
expect(call?.mode).toBe("trusted-proxy");
expect(call?.trustedProxy).toEqual({
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
allowUsers: ["nick@example.com"],
});
expect(result.config.gateway?.bind).toBe("lan");
expect(result.config.gateway?.trustedProxies).toEqual(["10.0.1.10", "192.168.1.5"]);
});
it("handles trusted-proxy with no optional fields", async () => {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
const selectQueue = ["loopback", "trusted-proxy", "off"];
mocks.select.mockImplementation(async () => selectQueue.shift());
// Port prompt, userHeader (only required), empty requiredHeaders, empty allowUsers, trustedProxies
const textQueue = ["18789", "x-remote-user", "", "", "10.0.0.1"];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
const call = mocks.buildGatewayAuthConfig.mock.calls[0]?.[0];
expect(call?.mode).toBe("trusted-proxy");
expect(call?.trustedProxy).toEqual({
userHeader: "x-remote-user",
// requiredHeaders and allowUsers should be undefined when empty
});
expect(result.config.gateway?.bind).toBe("lan");
expect(result.config.gateway?.trustedProxies).toEqual(["10.0.0.1"]);
});
it("forces tailscale off when trusted-proxy is selected", async () => {
vi.clearAllMocks();
mocks.resolveGatewayPort.mockReturnValue(18789);
const selectQueue = ["loopback", "trusted-proxy", "serve"];
mocks.select.mockImplementation(async () => selectQueue.shift());
const textQueue = ["18789", "x-forwarded-user", "", "", "10.0.0.1"];
mocks.text.mockImplementation(async () => textQueue.shift());
mocks.confirm.mockResolvedValue(true);
mocks.buildGatewayAuthConfig.mockImplementation(({ mode, trustedProxy }) => ({
mode,
trustedProxy,
}));
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await promptGatewayConfig({}, runtime);
expect(result.config.gateway?.bind).toBe("lan");
expect(result.config.gateway?.tailscale?.mode).toBe("off");
expect(result.config.gateway?.tailscale?.resetOnExit).toBe(false);
});
});

View File

@@ -12,7 +12,7 @@ import {
validateGatewayPasswordInput,
} from "./onboard-helpers.js";
type GatewayAuthChoice = "token" | "password";
type GatewayAuthChoice = "token" | "password" | "trusted-proxy";
export async function promptGatewayConfig(
cfg: OpenClawConfig,
@@ -103,13 +103,18 @@ export async function promptGatewayConfig(
options: [
{ value: "token", label: "Token", hint: "Recommended default" },
{ value: "password", label: "Password" },
{
value: "trusted-proxy",
label: "Trusted Proxy",
hint: "Behind reverse proxy (Pomerium, Caddy, Traefik, etc.)",
},
],
initialValue: "token",
}),
runtime,
) as GatewayAuthChoice;
const tailscaleMode = guardCancel(
let tailscaleMode = guardCancel(
await select({
message: "Tailscale exposure",
options: [
@@ -175,8 +180,25 @@ export async function promptGatewayConfig(
authMode = "password";
}
if (authMode === "trusted-proxy" && bind === "loopback") {
note("Trusted proxy auth requires network bind. Adjusting bind to lan.", "Note");
bind = "lan";
}
if (authMode === "trusted-proxy" && tailscaleMode !== "off") {
note(
"Trusted proxy auth is incompatible with Tailscale serve/funnel. Disabling Tailscale.",
"Note",
);
tailscaleMode = "off";
tailscaleResetOnExit = false;
}
let gatewayToken: string | undefined;
let gatewayPassword: string | undefined;
let trustedProxyConfig:
| { userHeader: string; requiredHeaders?: string[]; allowUsers?: string[] }
| undefined;
let trustedProxies: string[] | undefined;
let next = cfg;
if (authMode === "token") {
@@ -201,11 +223,88 @@ export async function promptGatewayConfig(
gatewayPassword = String(password ?? "").trim();
}
if (authMode === "trusted-proxy") {
note(
[
"Trusted proxy mode: OpenClaw trusts user identity from a reverse proxy.",
"The proxy must authenticate users and pass identity via headers.",
"Only requests from specified proxy IPs will be trusted.",
"",
"Common use cases: Pomerium, Caddy + OAuth, Traefik + forward auth",
"Docs: https://docs.openclaw.ai/gateway/trusted-proxy-auth",
].join("\n"),
"Trusted Proxy Auth",
);
const userHeader = guardCancel(
await text({
message: "Header containing user identity",
placeholder: "x-forwarded-user",
initialValue: "x-forwarded-user",
validate: (value) => (value?.trim() ? undefined : "User header is required"),
}),
runtime,
);
const requiredHeadersRaw = guardCancel(
await text({
message: "Required headers (comma-separated, optional)",
placeholder: "x-forwarded-proto,x-forwarded-host",
}),
runtime,
);
const requiredHeaders = requiredHeadersRaw
? String(requiredHeadersRaw)
.split(",")
.map((h) => h.trim())
.filter(Boolean)
: [];
const allowUsersRaw = guardCancel(
await text({
message: "Allowed users (comma-separated, blank = all authenticated users)",
placeholder: "nick@example.com,admin@company.com",
}),
runtime,
);
const allowUsers = allowUsersRaw
? String(allowUsersRaw)
.split(",")
.map((u) => u.trim())
.filter(Boolean)
: [];
const trustedProxiesRaw = guardCancel(
await text({
message: "Trusted proxy IPs (comma-separated)",
placeholder: "10.0.1.10,192.168.1.5",
validate: (value) => {
if (!value || String(value).trim() === "") {
return "At least one trusted proxy IP is required";
}
return undefined;
},
}),
runtime,
);
trustedProxies = String(trustedProxiesRaw)
.split(",")
.map((ip) => ip.trim())
.filter(Boolean);
trustedProxyConfig = {
userHeader: String(userHeader).trim(),
requiredHeaders: requiredHeaders.length > 0 ? requiredHeaders : undefined,
allowUsers: allowUsers.length > 0 ? allowUsers : undefined,
};
}
const authConfig = buildGatewayAuthConfig({
existing: next.gateway?.auth,
mode: authMode,
token: gatewayToken,
password: gatewayPassword,
trustedProxy: trustedProxyConfig,
});
next = {
@@ -217,6 +316,7 @@ export async function promptGatewayConfig(
bind,
auth: authConfig,
...(customBindHost && { customBindHost }),
...(trustedProxies && { trustedProxies }),
tailscale: {
...next.gateway?.tailscale,
mode: tailscaleMode,

View File

@@ -76,7 +76,32 @@ export type GatewayControlUiConfig = {
dangerouslyDisableDeviceAuth?: boolean;
};
export type GatewayAuthMode = "token" | "password";
export type GatewayAuthMode = "token" | "password" | "trusted-proxy";
/**
* Configuration for trusted reverse proxy authentication.
* Used when Clawdbot runs behind an identity-aware proxy (Pomerium, Caddy + OAuth, etc.)
* that handles authentication and passes user identity via headers.
*/
export type GatewayTrustedProxyConfig = {
/**
* Header name containing the authenticated user identity (required).
* Common values: "x-forwarded-user", "x-remote-user", "x-pomerium-claim-email"
*/
userHeader: string;
/**
* Additional headers that MUST be present for the request to be trusted.
* Use this to verify the request actually came through the proxy.
* Example: ["x-forwarded-proto", "x-forwarded-host"]
*/
requiredHeaders?: string[];
/**
* Optional allowlist of user identities that can access the gateway.
* If empty or omitted, all authenticated users from the proxy are allowed.
* Example: ["nick@example.com", "admin@company.org"]
*/
allowUsers?: string[];
};
export type GatewayAuthConfig = {
/** Authentication mode for Gateway connections. Defaults to token when set. */
@@ -89,6 +114,11 @@ export type GatewayAuthConfig = {
allowTailscale?: boolean;
/** Rate-limit configuration for failed authentication attempts. */
rateLimit?: GatewayAuthRateLimitConfig;
/**
* Configuration for trusted-proxy auth mode.
* Required when mode is "trusted-proxy".
*/
trustedProxy?: GatewayTrustedProxyConfig;
};
export type GatewayAuthRateLimitConfig = {

View File

@@ -398,10 +398,29 @@ export const OpenClawSchema = z
.optional(),
auth: z
.object({
mode: z.union([z.literal("token"), z.literal("password")]).optional(),
mode: z
.union([z.literal("token"), z.literal("password"), z.literal("trusted-proxy")])
.optional(),
token: z.string().optional().register(sensitive),
password: z.string().optional().register(sensitive),
allowTailscale: z.boolean().optional(),
rateLimit: z
.object({
maxAttempts: z.number().optional(),
windowMs: z.number().optional(),
lockoutMs: z.number().optional(),
exemptLoopback: z.boolean().optional(),
})
.strict()
.optional(),
trustedProxy: z
.object({
userHeader: z.string().min(1, "userHeader is required for trusted-proxy mode"),
requiredHeaders: z.array(z.string()).optional(),
allowUsers: z.array(z.string()).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthRateLimiter } from "./auth-rate-limit.js";
import { authorizeGatewayConnect } from "./auth.js";
import { authorizeGatewayConnect, resolveGatewayAuth } from "./auth.js";
function createLimiterSpy(): AuthRateLimiter & {
check: ReturnType<typeof vi.fn>;
@@ -18,6 +18,38 @@ function createLimiterSpy(): AuthRateLimiter & {
}
describe("gateway auth", () => {
it("resolves token/password from OPENCLAW gateway env vars", () => {
expect(
resolveGatewayAuth({
authConfig: {},
env: {
OPENCLAW_GATEWAY_TOKEN: "env-token",
OPENCLAW_GATEWAY_PASSWORD: "env-password",
} as NodeJS.ProcessEnv,
}),
).toMatchObject({
mode: "password",
token: "env-token",
password: "env-password",
});
});
it("does not resolve legacy CLAWDBOT gateway env vars", () => {
expect(
resolveGatewayAuth({
authConfig: {},
env: {
CLAWDBOT_GATEWAY_TOKEN: "legacy-token",
CLAWDBOT_GATEWAY_PASSWORD: "legacy-password",
} as NodeJS.ProcessEnv,
}),
).toMatchObject({
mode: "none",
token: undefined,
password: undefined,
});
});
it("does not throw when req is missing socket", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: false },
@@ -149,3 +181,250 @@ describe("gateway auth", () => {
expect(limiter.recordFailure).toHaveBeenCalledWith(undefined, "custom-scope");
});
});
describe("trusted-proxy auth", () => {
const trustedProxyConfig = {
userHeader: "x-forwarded-user",
requiredHeaders: ["x-forwarded-proto"],
allowUsers: [],
};
it("accepts valid request from trusted proxy", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: trustedProxyConfig,
},
connectAuth: null,
trustedProxies: ["10.0.0.1"],
req: {
socket: { remoteAddress: "10.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-user": "nick@example.com",
"x-forwarded-proto": "https",
},
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("trusted-proxy");
expect(res.user).toBe("nick@example.com");
});
it("rejects request from untrusted source", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: trustedProxyConfig,
},
connectAuth: null,
trustedProxies: ["10.0.0.1"],
req: {
socket: { remoteAddress: "192.168.1.100" },
headers: {
host: "gateway.local",
"x-forwarded-user": "attacker@evil.com",
"x-forwarded-proto": "https",
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("trusted_proxy_untrusted_source");
});
it("rejects request with missing user header", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: trustedProxyConfig,
},
connectAuth: null,
trustedProxies: ["10.0.0.1"],
req: {
socket: { remoteAddress: "10.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-proto": "https",
// missing x-forwarded-user
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("trusted_proxy_user_missing");
});
it("rejects request with missing required headers", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: trustedProxyConfig,
},
connectAuth: null,
trustedProxies: ["10.0.0.1"],
req: {
socket: { remoteAddress: "10.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-user": "nick@example.com",
// missing x-forwarded-proto
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("trusted_proxy_missing_header_x-forwarded-proto");
});
it("rejects user not in allowlist", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: {
userHeader: "x-forwarded-user",
allowUsers: ["admin@example.com", "nick@example.com"],
},
},
connectAuth: null,
trustedProxies: ["10.0.0.1"],
req: {
socket: { remoteAddress: "10.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-user": "stranger@other.com",
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("trusted_proxy_user_not_allowed");
});
it("accepts user in allowlist", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: {
userHeader: "x-forwarded-user",
allowUsers: ["admin@example.com", "nick@example.com"],
},
},
connectAuth: null,
trustedProxies: ["10.0.0.1"],
req: {
socket: { remoteAddress: "10.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-user": "nick@example.com",
},
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("trusted-proxy");
expect(res.user).toBe("nick@example.com");
});
it("rejects when no trustedProxies configured", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: trustedProxyConfig,
},
connectAuth: null,
trustedProxies: [],
req: {
socket: { remoteAddress: "10.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-user": "nick@example.com",
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("trusted_proxy_no_proxies_configured");
});
it("rejects when trustedProxy config missing", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
// trustedProxy missing
},
connectAuth: null,
trustedProxies: ["10.0.0.1"],
req: {
socket: { remoteAddress: "10.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-user": "nick@example.com",
},
} as never,
});
expect(res.ok).toBe(false);
expect(res.reason).toBe("trusted_proxy_config_missing");
});
it("supports Pomerium-style headers", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: {
userHeader: "x-pomerium-claim-email",
requiredHeaders: ["x-pomerium-jwt-assertion"],
},
},
connectAuth: null,
trustedProxies: ["172.17.0.1"],
req: {
socket: { remoteAddress: "172.17.0.1" },
headers: {
host: "gateway.local",
"x-pomerium-claim-email": "nick@example.com",
"x-pomerium-jwt-assertion": "eyJ...",
},
} as never,
});
expect(res.ok).toBe(true);
expect(res.method).toBe("trusted-proxy");
expect(res.user).toBe("nick@example.com");
});
it("trims whitespace from user header value", async () => {
const res = await authorizeGatewayConnect({
auth: {
mode: "trusted-proxy",
allowTailscale: false,
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
connectAuth: null,
trustedProxies: ["10.0.0.1"],
req: {
socket: { remoteAddress: "10.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-user": " nick@example.com ",
},
} as never,
});
expect(res.ok).toBe(true);
expect(res.user).toBe("nick@example.com");
});
});

View File

@@ -1,5 +1,9 @@
import type { IncomingMessage } from "node:http";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
import type {
GatewayAuthConfig,
GatewayTailscaleMode,
GatewayTrustedProxyConfig,
} from "../config/config.js";
import { readTailscaleWhoisIdentity, type TailscaleWhoisIdentity } from "../infra/tailscale.js";
import { safeEqualSecret } from "../security/secret-equal.js";
import {
@@ -14,18 +18,19 @@ import {
resolveGatewayClientIp,
} from "./net.js";
export type ResolvedGatewayAuthMode = "token" | "password";
export type ResolvedGatewayAuthMode = "none" | "token" | "password" | "trusted-proxy";
export type ResolvedGatewayAuth = {
mode: ResolvedGatewayAuthMode;
token?: string;
password?: string;
allowTailscale: boolean;
trustedProxy?: GatewayTrustedProxyConfig;
};
export type GatewayAuthResult = {
ok: boolean;
method?: "token" | "password" | "tailscale" | "device-token";
method?: "none" | "token" | "password" | "tailscale" | "device-token" | "trusted-proxy";
user?: string;
reason?: string;
/** Present when the request was blocked by the rate limiter. */
@@ -192,21 +197,31 @@ export function resolveGatewayAuth(params: {
}): ResolvedGatewayAuth {
const authConfig = params.authConfig ?? {};
const env = params.env ?? process.env;
const token =
authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
const password =
authConfig.password ??
env.OPENCLAW_GATEWAY_PASSWORD ??
env.CLAWDBOT_GATEWAY_PASSWORD ??
undefined;
const mode: ResolvedGatewayAuth["mode"] = authConfig.mode ?? (password ? "password" : "token");
const token = authConfig.token ?? env.OPENCLAW_GATEWAY_TOKEN ?? undefined;
const password = authConfig.password ?? env.OPENCLAW_GATEWAY_PASSWORD ?? undefined;
const trustedProxy = authConfig.trustedProxy;
let mode: ResolvedGatewayAuth["mode"];
if (authConfig.mode) {
mode = authConfig.mode;
} else if (password) {
mode = "password";
} else if (token) {
mode = "token";
} else {
mode = "none";
}
const allowTailscale =
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
authConfig.allowTailscale ??
(params.tailscaleMode === "serve" && mode !== "password" && mode !== "trusted-proxy");
return {
mode,
token,
password,
allowTailscale,
trustedProxy,
};
}
@@ -222,6 +237,61 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
if (auth.mode === "password" && !auth.password) {
throw new Error("gateway auth mode is password, but no password was configured");
}
if (auth.mode === "trusted-proxy") {
if (!auth.trustedProxy) {
throw new Error(
"gateway auth mode is trusted-proxy, but no trustedProxy config was provided (set gateway.auth.trustedProxy)",
);
}
if (!auth.trustedProxy.userHeader || auth.trustedProxy.userHeader.trim() === "") {
throw new Error(
"gateway auth mode is trusted-proxy, but trustedProxy.userHeader is empty (set gateway.auth.trustedProxy.userHeader)",
);
}
}
}
/**
* Check if the request came from a trusted proxy and extract user identity.
* Returns the user identity if valid, or null with a reason if not.
*/
function authorizeTrustedProxy(params: {
req?: IncomingMessage;
trustedProxies?: string[];
trustedProxyConfig: GatewayTrustedProxyConfig;
}): { user: string } | { reason: string } {
const { req, trustedProxies, trustedProxyConfig } = params;
if (!req) {
return { reason: "trusted_proxy_no_request" };
}
const remoteAddr = req.socket?.remoteAddress;
if (!remoteAddr || !isTrustedProxyAddress(remoteAddr, trustedProxies)) {
return { reason: "trusted_proxy_untrusted_source" };
}
const requiredHeaders = trustedProxyConfig.requiredHeaders ?? [];
for (const header of requiredHeaders) {
const value = headerValue(req.headers[header.toLowerCase()]);
if (!value || value.trim() === "") {
return { reason: `trusted_proxy_missing_header_${header}` };
}
}
const userHeaderValue = headerValue(req.headers[trustedProxyConfig.userHeader.toLowerCase()]);
if (!userHeaderValue || userHeaderValue.trim() === "") {
return { reason: "trusted_proxy_user_missing" };
}
const user = userHeaderValue.trim();
const allowUsers = trustedProxyConfig.allowUsers ?? [];
if (allowUsers.length > 0 && !allowUsers.includes(user)) {
return { reason: "trusted_proxy_user_not_allowed" };
}
return { user };
}
export async function authorizeGatewayConnect(params: {
@@ -241,7 +311,26 @@ export async function authorizeGatewayConnect(params: {
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const localDirect = isLocalDirectRequest(req, trustedProxies);
// --- Rate-limit gate ---
if (auth.mode === "trusted-proxy") {
if (!auth.trustedProxy) {
return { ok: false, reason: "trusted_proxy_config_missing" };
}
if (!trustedProxies || trustedProxies.length === 0) {
return { ok: false, reason: "trusted_proxy_no_proxies_configured" };
}
const result = authorizeTrustedProxy({
req,
trustedProxies,
trustedProxyConfig: auth.trustedProxy,
});
if ("user" in result) {
return { ok: true, method: "trusted-proxy", user: result.user };
}
return { ok: false, reason: result.reason };
}
const limiter = params.rateLimiter;
const ip =
params.clientIp ?? resolveRequestClientIp(req, trustedProxies) ?? req?.socket?.remoteAddress;
@@ -264,7 +353,6 @@ export async function authorizeGatewayConnect(params: {
tailscaleWhois,
});
if (tailscaleCheck.ok) {
// Successful auth reset rate-limit counter for this IP.
limiter?.reset(ip, rateLimitScope);
return {
ok: true,

View File

@@ -2,10 +2,108 @@ import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isPrivateOrLoopbackAddress,
isTrustedProxyAddress,
pickPrimaryLanIPv4,
resolveGatewayListenHosts,
} from "./net.js";
describe("isTrustedProxyAddress", () => {
describe("exact IP matching", () => {
it("returns true when IP matches exactly", () => {
expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true);
});
it("returns false when IP does not match", () => {
expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false);
});
it("returns true when IP matches one of multiple proxies", () => {
expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5", "172.16.0.1"])).toBe(
true,
);
});
});
describe("CIDR subnet matching", () => {
it("returns true when IP is within /24 subnet", () => {
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/24"])).toBe(true);
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/24"])).toBe(true);
expect(isTrustedProxyAddress("10.42.0.254", ["10.42.0.0/24"])).toBe(true);
});
it("returns false when IP is outside /24 subnet", () => {
expect(isTrustedProxyAddress("10.42.1.1", ["10.42.0.0/24"])).toBe(false);
expect(isTrustedProxyAddress("10.43.0.1", ["10.42.0.0/24"])).toBe(false);
});
it("returns true when IP is within /16 subnet", () => {
expect(isTrustedProxyAddress("172.19.5.100", ["172.19.0.0/16"])).toBe(true);
expect(isTrustedProxyAddress("172.19.255.255", ["172.19.0.0/16"])).toBe(true);
});
it("returns false when IP is outside /16 subnet", () => {
expect(isTrustedProxyAddress("172.20.0.1", ["172.19.0.0/16"])).toBe(false);
});
it("returns true when IP is within /32 subnet (single IP)", () => {
expect(isTrustedProxyAddress("10.42.0.0", ["10.42.0.0/32"])).toBe(true);
});
it("returns false when IP does not match /32 subnet", () => {
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/32"])).toBe(false);
});
it("handles mixed exact IPs and CIDR notation", () => {
const proxies = ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"];
expect(isTrustedProxyAddress("192.168.1.1", proxies)).toBe(true); // exact match
expect(isTrustedProxyAddress("10.42.0.59", proxies)).toBe(true); // CIDR match
expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match
expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match
});
});
describe("backward compatibility", () => {
it("preserves exact IP matching behavior (no CIDR notation)", () => {
// Old configs with exact IPs should work exactly as before
expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true);
expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false);
expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5"])).toBe(true);
});
it("does NOT treat plain IPs as /32 CIDR (exact match only)", () => {
// "10.42.0.1" without /32 should match ONLY that exact IP
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.1"])).toBe(true);
expect(isTrustedProxyAddress("10.42.0.2", ["10.42.0.1"])).toBe(false);
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.1"])).toBe(false);
});
it("handles IPv4-mapped IPv6 addresses (existing normalizeIp behavior)", () => {
// Existing normalizeIp() behavior should be preserved
expect(isTrustedProxyAddress("::ffff:192.168.1.1", ["192.168.1.1"])).toBe(true);
});
});
describe("edge cases", () => {
it("returns false when IP is undefined", () => {
expect(isTrustedProxyAddress(undefined, ["192.168.1.1"])).toBe(false);
});
it("returns false when trustedProxies is undefined", () => {
expect(isTrustedProxyAddress("192.168.1.1", undefined)).toBe(false);
});
it("returns false when trustedProxies is empty", () => {
expect(isTrustedProxyAddress("192.168.1.1", [])).toBe(false);
});
it("returns false for invalid CIDR notation", () => {
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/33"])).toBe(false); // invalid prefix
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/-1"])).toBe(false); // negative prefix
expect(isTrustedProxyAddress("10.42.0.59", ["invalid/24"])).toBe(false); // invalid IP
});
});
});
describe("resolveGatewayListenHosts", () => {
it("returns the input host when not loopback", async () => {
const hosts = await resolveGatewayListenHosts("0.0.0.0", {

View File

@@ -139,12 +139,65 @@ function parseRealIp(realIp?: string): string | undefined {
return normalizeIp(stripOptionalPort(raw));
}
/**
* Check if an IP address matches a CIDR block.
* Supports IPv4 CIDR notation (e.g., "10.42.0.0/24").
*
* @param ip - The IP address to check (e.g., "10.42.0.59")
* @param cidr - The CIDR block (e.g., "10.42.0.0/24")
* @returns True if the IP is within the CIDR block
*/
function ipMatchesCIDR(ip: string, cidr: string): boolean {
// Handle exact IP match (no CIDR notation)
if (!cidr.includes("/")) {
return ip === cidr;
}
const [subnet, prefixLenStr] = cidr.split("/");
const prefixLen = parseInt(prefixLenStr, 10);
// Validate prefix length
if (Number.isNaN(prefixLen) || prefixLen < 0 || prefixLen > 32) {
return false;
}
// Convert IPs to 32-bit integers
const ipParts = ip.split(".").map((p) => parseInt(p, 10));
const subnetParts = subnet.split(".").map((p) => parseInt(p, 10));
// Validate IP format
if (
ipParts.length !== 4 ||
subnetParts.length !== 4 ||
ipParts.some((p) => Number.isNaN(p) || p < 0 || p > 255) ||
subnetParts.some((p) => Number.isNaN(p) || p < 0 || p > 255)
) {
return false;
}
const ipInt = (ipParts[0] << 24) | (ipParts[1] << 16) | (ipParts[2] << 8) | ipParts[3];
const subnetInt =
(subnetParts[0] << 24) | (subnetParts[1] << 16) | (subnetParts[2] << 8) | subnetParts[3];
// Create mask and compare
const mask = prefixLen === 0 ? 0 : (-1 >>> (32 - prefixLen)) << (32 - prefixLen);
return (ipInt & mask) === (subnetInt & mask);
}
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
const normalized = normalizeIp(ip);
if (!normalized || !trustedProxies || trustedProxies.length === 0) {
return false;
}
return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized);
return trustedProxies.some((proxy) => {
// Handle CIDR notation
if (proxy.includes("/")) {
return ipMatchesCIDR(normalized, proxy);
}
// Exact IP match
return normalizeIp(proxy) === normalized;
});
}
export function resolveGatewayClientIp(params: {

View File

@@ -52,6 +52,14 @@ export const SnapshotSchema = Type.Object(
configPath: Type.Optional(NonEmptyString),
stateDir: Type.Optional(NonEmptyString),
sessionDefaults: Type.Optional(SessionDefaultsSchema),
authMode: Type.Optional(
Type.Union([
Type.Literal("none"),
Type.Literal("token"),
Type.Literal("password"),
Type.Literal("trusted-proxy"),
]),
),
},
{ additionalProperties: false },
);

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
describe("resolveGatewayRuntimeConfig", () => {
describe("trusted-proxy auth mode", () => {
// This test validates BOTH validation layers:
// 1. CLI validation in src/cli/gateway-cli/run.ts (line 246)
// 2. Runtime config validation in src/gateway/server-runtime-config.ts (line 99)
// Both must allow lan binding when authMode === "trusted-proxy"
it("should allow lan binding with trusted-proxy auth mode", async () => {
const cfg = {
gateway: {
bind: "lan" as const,
auth: {
mode: "trusted-proxy" as const,
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
trustedProxies: ["192.168.1.1"],
},
};
const result = await resolveGatewayRuntimeConfig({
cfg,
port: 18789,
});
expect(result.authMode).toBe("trusted-proxy");
expect(result.bindHost).toBe("0.0.0.0");
});
it("should reject loopback binding with trusted-proxy auth mode", async () => {
const cfg = {
gateway: {
bind: "loopback" as const,
auth: {
mode: "trusted-proxy" as const,
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
trustedProxies: ["192.168.1.1"],
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
}),
).rejects.toThrow("gateway auth mode=trusted-proxy makes no sense with bind=loopback");
});
it("should reject trusted-proxy without trustedProxies configured", async () => {
const cfg = {
gateway: {
bind: "lan" as const,
auth: {
mode: "trusted-proxy" as const,
trustedProxy: {
userHeader: "x-forwarded-user",
},
},
trustedProxies: [],
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
}),
).rejects.toThrow(
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured",
);
});
});
describe("token/password auth modes", () => {
it("should reject token mode without token configured", async () => {
const cfg = {
gateway: {
bind: "lan" as const,
auth: {
mode: "token" as const,
},
},
};
await expect(
resolveGatewayRuntimeConfig({
cfg,
port: 18789,
}),
).rejects.toThrow("gateway auth mode is token, but no token was configured");
});
it("should allow lan binding with token", async () => {
const cfg = {
gateway: {
bind: "lan" as const,
auth: {
mode: "token" as const,
token: "test-token-123",
},
},
};
const result = await resolveGatewayRuntimeConfig({
cfg,
port: 18789,
});
expect(result.authMode).toBe("token");
expect(result.bindHost).toBe("0.0.0.0");
});
});
});

View File

@@ -85,6 +85,8 @@ export async function resolveGatewayRuntimeConfig(params: {
const canvasHostEnabled =
process.env.OPENCLAW_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
const trustedProxies = params.cfg.gateway?.trustedProxies ?? [];
assertGatewayAuthConfigured(resolvedAuth);
if (tailscaleMode === "funnel" && authMode !== "password") {
throw new Error(
@@ -94,12 +96,25 @@ export async function resolveGatewayRuntimeConfig(params: {
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
}
if (!isLoopbackHost(bindHost) && !hasSharedSecret) {
if (!isLoopbackHost(bindHost) && !hasSharedSecret && authMode !== "trusted-proxy") {
throw new Error(
`refusing to bind gateway to ${bindHost}:${params.port} without auth (set gateway.auth.token/password, or set OPENCLAW_GATEWAY_TOKEN/OPENCLAW_GATEWAY_PASSWORD)`,
);
}
if (authMode === "trusted-proxy") {
if (isLoopbackHost(bindHost)) {
throw new Error(
"gateway auth mode=trusted-proxy makes no sense with bind=loopback; use bind=lan or bind=custom with gateway.trustedProxies configured",
);
}
if (trustedProxies.length === 0) {
throw new Error(
"gateway auth mode=trusted-proxy requires gateway.trustedProxies to be configured with at least one proxy IP",
);
}
}
return {
bindHost,
controlUiEnabled,

View File

@@ -5,6 +5,7 @@ import { CONFIG_PATH, STATE_DIR, loadConfig } from "../../config/config.js";
import { resolveMainSessionKey } from "../../config/sessions.js";
import { listSystemPresence } from "../../infra/system-presence.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { resolveGatewayAuth } from "../auth.js";
let presenceVersion = 1;
let healthVersion = 1;
@@ -20,6 +21,7 @@ export function buildGatewaySnapshot(): Snapshot {
const scope = cfg.session?.scope ?? "per-sender";
const presence = listSystemPresence();
const uptimeMs = Math.round(process.uptime() * 1000);
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
// Health is async; caller should await getHealthSnapshot and replace later if needed.
const emptyHealth: unknown = {};
return {
@@ -36,6 +38,7 @@ export function buildGatewaySnapshot(): Snapshot {
mainSessionKey,
scope,
},
authMode: auth.mode,
};
}

View File

@@ -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[] = [];

View File

@@ -11,6 +11,7 @@
export {
collectAttackSurfaceSummaryFindings,
collectExposureMatrixFindings,
collectGatewayHttpSessionKeyOverrideFindings,
collectHooksHardeningFindings,
collectMinimalProfileOverrideFindings,
collectModelHygieneFindings,

View File

@@ -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[] = [

View File

@@ -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));