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
- 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.

View File

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

View File

@@ -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(

View File

@@ -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<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 { 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<string, HookAuthFailure>();
const resolveHookClientKey = (req: IncomingMessage): string => {
return req.socket?.remoteAddress?.trim() || "unknown";
return normalizeRateLimitClientIp(req.socket?.remoteAddress);
};
const recordHookAuthFailure = (