From ae972fe1fe74f38ef4259e1fced09cda11ab9c8e Mon Sep 17 00:00:00 2001 From: Agustin Rivera <31522568+eleqtrizit@users.noreply.github.com> Date: Tue, 26 May 2026 22:29:33 -0700 Subject: [PATCH] fix(gateway): enable default auth rate limiting (#87148) * fix(gateway): enable default auth rate limiting * fix(gateway): update auth rate limit changelog --- CHANGELOG.md | 1 + .../server.auth.browser-hardening.test.ts | 33 +++++++++++++++++++ src/gateway/server.impl.ts | 8 +++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8166aae3379..1ca002d52f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Memory/security: reject prompt-like text submitted through the explicit `memory_store` tool before embedding or storage, matching the existing auto-capture prompt-injection filter. (#87142) +- Gateway/security: enable the default auth rate limiter for remote non-browser and HTTP gateway auth failures when `gateway.auth.rateLimit` is unset, while preserving the loopback exemption. (#87148) - Security/content boundaries: validate Browser snapshot tab URLs against SSRF policy before ChromeMCP or direct CDP reads, sanitize queued system-event text so untrusted plugin/channel labels cannot spoof nested prompt markers, wrap fetched file text and metadata as external content, apply ClickClack `allowFrom` sender allowlists before agent dispatch, reject RPCs from invalidated device-token clients during rotation, require staged sandbox media refs, and scrub serialized tool-call text from replies. (#78526, #87094, #87062, #83741, #70707, #86924) Thanks @zsxsoft, @ttzero25, and @mmaps. - Transcripts/user turns: persist CLI, WebChat, media, follow-up, hook, and Codex-mirror user turns to the admitted session target; keep cleaned transcript text, inline image routing, provenance metadata, replay hooks, and fallback paths idempotent when runtimes fail or restart. - TUI/status/onboarding/UI: queue busy TUI prompts instead of dropping them, preserve the configured default model during onboarding, show failed tool results as errors, show config-open failures in Control UI, keep status JSON plugin scans healthy, preserve xAI usage-limit errors locally, and expose explicit fast-mode/systemd state. (#86722, #87000, #85786, #87108, #87001, #86614, #87115, #86976) diff --git a/src/gateway/server.auth.browser-hardening.test.ts b/src/gateway/server.auth.browser-hardening.test.ts index 2ca8b33863d..5c74498a15c 100644 --- a/src/gateway/server.auth.browser-hardening.test.ts +++ b/src/gateway/server.auth.browser-hardening.test.ts @@ -280,6 +280,39 @@ describe("gateway auth browser hardening", () => { }); }); + test("rate-limits non-browser remote auth failures by default", async () => { + const { writeConfigFile } = await import("../config/config.js"); + testState.gatewayAuth = { mode: "token", token: "secret" }; + await writeConfigFile({ + gateway: { + trustedProxies: ["127.0.0.1"], + }, + }); + + await withGatewayServer(async ({ port }) => { + const remoteHeaders = { "x-forwarded-for": "203.0.113.50" }; + for (let attempt = 1; attempt <= 10; attempt += 1) { + const ws = await openWs(port, remoteHeaders); + try { + const res = await connectReq(ws, { token: "wrong", device: null }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").not.toContain("retry later"); + } finally { + ws.close(); + } + } + + const lockedWs = await openWs(port, remoteHeaders); + try { + const locked = await connectReq(lockedWs, { token: "wrong", device: null }); + expect(locked.ok).toBe(false); + expect(locked.error?.message ?? "").toContain("retry later"); + } finally { + lockedWs.close(); + } + }); + }); + test("isolates loopback browser-origin auth lockouts per origin", async () => { testState.gatewayAuth = { mode: "token", diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index a1873d7ef7b..28f8f3f3cdf 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -446,10 +446,12 @@ async function stopTaskRegistryMaintenanceOnDemand(): Promise { type AuthRateLimitConfig = Parameters[0]; function createGatewayAuthRateLimiters(rateLimitConfig: AuthRateLimitConfig | undefined): { - rateLimiter?: AuthRateLimiter; + rateLimiter: AuthRateLimiter; browserRateLimiter: AuthRateLimiter; } { - const rateLimiter = rateLimitConfig ? createAuthRateLimiter(rateLimitConfig) : undefined; + // Keep remote non-browser and HTTP auth attempts throttled by default while + // preserving the normal loopback exemption unless operators configure otherwise. + const rateLimiter = createAuthRateLimiter(rateLimitConfig ?? {}); // Browser-origin WS auth attempts always use loopback-non-exempt throttling. const browserRateLimiter = createAuthRateLimiter({ ...rateLimitConfig, @@ -984,7 +986,7 @@ export async function startGatewayServer( runtimeState.skillsRefreshTimer = null; }, skillsChangeUnsub: runtimeState.skillsChangeUnsub, - ...(authRateLimiter ? { disposeAuthRateLimiter: () => authRateLimiter.dispose() } : {}), + disposeAuthRateLimiter: () => authRateLimiter.dispose(), disposeBrowserAuthRateLimiter: () => browserAuthRateLimiter.dispose(), stopModelPricingRefresh: runtimeState.stopModelPricingRefresh, stopChannelHealthMonitor: () => runtimeState?.channelHealthMonitor?.stop(),