mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix(security): normalize hook auth rate-limit client keys
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user