fix(security): normalize hook auth rate-limit client keys

This commit is contained in:
Peter Steinberger
2026-02-22 08:40:39 +01:00
parent aab20e58d7
commit 3284d2eb22
5 changed files with 63 additions and 7 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### 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/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/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. - 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. - 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.

View File

@@ -93,6 +93,12 @@ describe("auth rate limiter", () => {
expect(limiter.check("10.0.0.11").remaining).toBe(2); 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", () => { it("tracks scopes independently for the same IP", () => {
limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 }); limiter = createAuthRateLimiter({ maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000 });
limiter.recordFailure("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET); limiter.recordFailure("10.0.0.12", AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET);

View File

@@ -16,7 +16,7 @@
* {@link createAuthRateLimiter} and pass it where needed. * {@link createAuthRateLimiter} and pass it where needed.
*/ */
import { isLoopbackAddress } from "./net.js"; import { isLoopbackAddress, resolveClientIp } from "./net.js";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -81,6 +81,14 @@ const PRUNE_INTERVAL_MS = 60_000; // prune stale entries every minute
// Implementation // 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 { export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter {
const maxAttempts = config?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS; const maxAttempts = config?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
const windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS; const windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
@@ -101,7 +109,7 @@ export function createAuthRateLimiter(config?: RateLimitConfig): AuthRateLimiter
} }
function normalizeIp(ip: string | undefined): string { function normalizeIp(ip: string | undefined): string {
return (ip ?? "").trim() || "unknown"; return normalizeRateLimitClientIp(ip);
} }
function resolveKey( function resolveKey(

View File

@@ -36,15 +36,18 @@ function createHooksConfig(): HooksConfigResolved {
}; };
} }
function createRequest(): IncomingMessage { function createRequest(params?: {
authorization?: string;
remoteAddress?: string;
}): IncomingMessage {
return { return {
method: "POST", method: "POST",
url: "/hooks/wake", url: "/hooks/wake",
headers: { headers: {
host: "127.0.0.1:18789", 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; } as IncomingMessage;
} }
@@ -96,4 +99,42 @@ describe("createHooksRequestHandler timeout status mapping", () => {
expect(dispatchWakeHook).not.toHaveBeenCalled(); expect(dispatchWakeHook).not.toHaveBeenCalled();
expect(dispatchAgentHook).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<typeof createSubsystemLogger>,
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));
});
}); });

View File

@@ -19,7 +19,7 @@ import { loadConfig } from "../config/config.js";
import type { createSubsystemLogger } from "../logging/subsystem.js"; import type { createSubsystemLogger } from "../logging/subsystem.js";
import { safeEqualSecret } from "../security/secret-equal.js"; import { safeEqualSecret } from "../security/secret-equal.js";
import { handleSlackHttpRequest } from "../slack/http/index.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 { import {
authorizeHttpGatewayConnect, authorizeHttpGatewayConnect,
isLocalDirectRequest, isLocalDirectRequest,
@@ -222,7 +222,7 @@ export function createHooksRequestHandler(
const hookAuthFailures = new Map<string, HookAuthFailure>(); const hookAuthFailures = new Map<string, HookAuthFailure>();
const resolveHookClientKey = (req: IncomingMessage): string => { const resolveHookClientKey = (req: IncomingMessage): string => {
return req.socket?.remoteAddress?.trim() || "unknown"; return normalizeRateLimitClientIp(req.socket?.remoteAddress);
}; };
const recordHookAuthFailure = ( const recordHookAuthFailure = (