mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 09:18:38 +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
|
### 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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
Reference in New Issue
Block a user