fix: harden trusted-proxy auth follow-ups

This commit is contained in:
Peter Steinberger
2026-02-14 12:29:26 +01:00
parent a7ba8cc553
commit 279d4b304f
7 changed files with 96 additions and 21 deletions

View File

@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.

View File

@@ -133,6 +133,7 @@ describe("promptGatewayConfig", () => {
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"]);
});
@@ -163,6 +164,32 @@ describe("promptGatewayConfig", () => {
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

@@ -114,7 +114,7 @@ export async function promptGatewayConfig(
runtime,
) as GatewayAuthChoice;
const tailscaleMode = guardCancel(
let tailscaleMode = guardCancel(
await select({
message: "Tailscale exposure",
options: [
@@ -180,6 +180,19 @@ 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:
@@ -218,7 +231,7 @@ export async function promptGatewayConfig(
"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",
"Docs: https://docs.openclaw.ai/gateway/trusted-proxy-auth",
].join("\n"),
"Trusted Proxy Auth",
);

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 },

View File

@@ -197,8 +197,8 @@ export function resolveGatewayAuth(params: {
}): ResolvedGatewayAuth {
const authConfig = params.authConfig ?? {};
const env = params.env ?? process.env;
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
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"];

View File

@@ -96,13 +96,13 @@ describe("security audit", () => {
it("flags non-loopback bind without auth as critical", async () => {
// Clear env tokens so resolveGatewayAuth defaults to mode=none
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
const prevPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
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;
try {
const cfg: ClawdbotConfig = {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
auth: {},
@@ -121,14 +121,14 @@ describe("security audit", () => {
} finally {
// Restore env
if (prevToken === undefined) {
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.OPENCLAW_GATEWAY_TOKEN;
} else {
process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
process.env.OPENCLAW_GATEWAY_TOKEN = prevToken;
}
if (prevPassword === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
} else {
process.env.CLAWDBOT_GATEWAY_PASSWORD = prevPassword;
process.env.OPENCLAW_GATEWAY_PASSWORD = prevPassword;
}
}
});
@@ -612,8 +612,8 @@ describe("security audit", () => {
);
});
it("flags trusted-proxy auth mode as critical warning", async () => {
const cfg: ClawdbotConfig = {
it("flags trusted-proxy auth mode without generic shared-secret findings", async () => {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"],
@@ -640,10 +640,12 @@ describe("security audit", () => {
}),
]),
);
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: ClawdbotConfig = {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
trustedProxies: [],
@@ -673,7 +675,7 @@ describe("security audit", () => {
});
it("flags trusted-proxy auth without userHeader configured", async () => {
const cfg: ClawdbotConfig = {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"],
@@ -701,7 +703,7 @@ describe("security audit", () => {
});
it("warns when trusted-proxy auth allows all users", async () => {
const cfg: ClawdbotConfig = {
const cfg: OpenClawConfig = {
gateway: {
bind: "lan",
trustedProxies: ["10.0.0.1"],

View File

@@ -258,7 +258,7 @@ function collectGatewayConfigFindings(
(auth.mode === "token" && hasToken) || (auth.mode === "password" && hasPassword);
const hasTailscaleAuth = auth.allowTailscale && tailscaleMode === "serve";
const hasGatewayAuth = hasSharedSecret || hasTailscaleAuth;
if (bind !== "loopback" && !hasSharedSecret) {
if (bind !== "loopback" && !hasSharedSecret && auth.mode !== "trusted-proxy") {
findings.push({
checkId: "gateway.bind_no_auth",
severity: "critical",
@@ -403,7 +403,7 @@ function collectGatewayConfigFindings(
}
}
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",