diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cac218f915..4d84ad124a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Security/Audit: add `openclaw security audit` detection for open group policies that expose runtime/filesystem tools without sandbox/workspace guards (`security.exposure.open_groups_with_runtime_or_fs`). +- Security/Hooks auth: normalize hook auth rate-limit client IP keys so IPv4 and IPv4-mapped IPv6 addresses share one throttle bucket, preventing dual-form auth-attempt budget bypasses. Thanks @aether-ai-agent for reporting. - Telegram/WSL2: disable `autoSelectFamily` by default on WSL2 and memoize WSL2 detection in Telegram network decision logic to avoid repeated sync `/proc/version` probes on fetch/send paths. (#21916) Thanks @MizukiMachine. - Telegram/Streaming: preserve archived draft preview mapping after flush and clean superseded reasoning preview bubbles so multi-message preview finals no longer cross-edit or orphan stale messages under send/rotation races. (#23202) Thanks @obviyus. - Slack/Slash commands: preserve the Bolt app receiver when registering external select options handlers so monitor startup does not crash on runtimes that require bound `app.options` calls. (#23209) Thanks @0xgaia. diff --git a/src/gateway/auth-rate-limit.test.ts b/src/gateway/auth-rate-limit.test.ts index 0eaee4be0b1..13ff65eb972 100644 --- a/src/gateway/auth-rate-limit.test.ts +++ b/src/gateway/auth-rate-limit.test.ts @@ -93,6 +93,12 @@ describe("auth rate limiter", () => { expect(limiter.check("10.0.0.11").remaining).toBe(2); }); + it("treats ipv4 and ipv4-mapped ipv6 forms as the same client", () => { + limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); + limiter.recordFailure("1.2.3.4"); + expect(limiter.check("::ffff:1.2.3.4").allowed).toBe(false); + }); + it("tracks scopes independently for the same IP", () => { limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); limiter.recordFailure("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); diff --git a/src/gateway/auth-rate-limit.ts b/src/gateway/auth-rate-limit.ts index 8eeaa395627..1516ce3dce8 100644 --- a/src/gateway/auth-rate-limit.ts +++ b/src/gateway/auth-rate-limit.ts @@ -16,7 +16,7 @@ * {@link createAuthRateLimiter} and pass it where needed. */ -import { isLoopbackAddress } from "./net.js"; +import { isLoopbackAddress, resolveClientIp } from "./net.js"; // --------------------------------------------------------------------------- // Types @@ -81,6 +81,14 @@ const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute // Implementation // --------------------------------------------------------------------------- +/** + * Canonicalize client IPs used for auth throttling so all call sites + * share one representation (including IPv4-mapped IPv6 forms). + */ +export function normalizeRateLimitClientIp(ip: string | undefined): string { + return resolveClientIp({ remoteAddr: ip }) ?? "unknown"; +} + export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter { const maxAttempts = config?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; const windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS; @@ -101,7 +109,7 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter } function normalizeIp(ip: string | undefined): string { - return (ip ?? "").trim() || "unknown"; + return normalizeRateLimitClientIp(ip); } function resolveKey( diff --git a/src/gateway/server-http.hooks-request-timeout.test.ts b/src/gateway/server-http.hooks-request-timeout.test.ts index e76c243d5c1..c791e8fea74 100644 --- a/src/gateway/server-http.hooks-request-timeout.test.ts +++ b/src/gateway/server-http.hooks-request-timeout.test.ts @@ -36,15 +36,18 @@ function createHooksConfig(): HooksConfigResolved { }; } -function createRequest(): IncomingMessage { +function createRequest(params?: { + authorization?: string; + remoteAddress?: string; +}): IncomingMessage { return { method: "POST", url: "/hooks/wake", headers: { host: "127.0.0.1:18789", - authorization: "Bearer hook-secret", + authorization: params?.authorization ?? "Bearer hook-secret", }, - socket: { remoteAddress: "127.0.0.1" }, + socket: { remoteAddress: params?.remoteAddress ?? "127.0.0.1" }, } as IncomingMessage; } @@ -96,4 +99,42 @@ describe("createHooksRequestHandler timeout status mapping", () => { expect(dispatchWakeHook).not.toHaveBeenCalled(); expect(dispatchAgentHook).not.toHaveBeenCalled(); }); + + test("shares hook auth rate-limit bucket across ipv4 and ipv4-mapped ipv6 forms", async () => { + const handler = createHooksRequestHandler({ + getHooksConfig: () => createHooksConfig(), + bindHost: "127.0.0.1", + port: 18789, + logHooks: { + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + } as unknown as ReturnType, + dispatchWakeHook: vi.fn(), + dispatchAgentHook: vi.fn(() => "run-1"), + }); + + for (let i = 0; i < 20; i++) { + const req = createRequest({ + authorization: "Bearer wrong", + remoteAddress: "1.2.3.4", + }); + const { res } = createResponse(); + const handled = await handler(req, res); + expect(handled).toBe(true); + expect(res.statusCode).toBe(401); + } + + const mappedReq = createRequest({ + authorization: "Bearer wrong", + remoteAddress: "::ffff:1.2.3.4", + }); + const { res: mappedRes, setHeader } = createResponse(); + const handled = await handler(mappedReq, mappedRes); + + expect(handled).toBe(true); + expect(mappedRes.statusCode).toBe(429); + expect(setHeader).toHaveBeenCalledWith("Retry-After", expect.any(String)); + }); }); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 1bf12bbf6b9..d178fc31892 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -19,7 +19,7 @@ import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { handleSlackHttpRequest } from "../slack/http/index.js"; -import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import { normalizeRateLimitClientIp, type AuthRateLimiter } from "./auth-rate-limit.js"; import { authorizeHttpGatewayConnect, isLocalDirectRequest, @@ -222,7 +222,7 @@ export function createHooksRequestHandler( const hookAuthFailures = new Map(); const resolveHookClientKey = (req: IncomingMessage): string => { - return req.socket?.remoteAddress?.trim() || "unknown"; + return normalizeRateLimitClientIp(req.socket?.remoteAddress); }; const recordHookAuthFailure = (