mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 12:48:38 +00:00
fix(security): harden webhook memory guards across channels
This commit is contained in:
@@ -6,7 +6,10 @@ import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
clearNostrProfileRateLimitStateForTest,
|
||||
createNostrProfileHttpHandler,
|
||||
getNostrProfileRateLimitStateSizeForTest,
|
||||
isNostrProfileRateLimitedForTest,
|
||||
type NostrProfileHttpContext,
|
||||
} from "./nostr-profile-http.js";
|
||||
|
||||
@@ -136,6 +139,7 @@ function mockSuccessfulProfileImport() {
|
||||
describe("nostr-profile-http", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearNostrProfileRateLimitStateForTest();
|
||||
});
|
||||
|
||||
describe("route matching", () => {
|
||||
@@ -358,6 +362,25 @@ describe("nostr-profile-http", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("caps tracked rate-limit keys to prevent unbounded growth", () => {
|
||||
const now = 1_000_000;
|
||||
for (let i = 0; i < 2_500; i += 1) {
|
||||
isNostrProfileRateLimitedForTest(`rate-cap-${i}`, now);
|
||||
}
|
||||
expect(getNostrProfileRateLimitStateSizeForTest()).toBeLessThanOrEqual(2_048);
|
||||
});
|
||||
|
||||
it("prunes stale rate-limit keys after the window elapses", () => {
|
||||
const now = 2_000_000;
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
isNostrProfileRateLimitedForTest(`rate-stale-${i}`, now);
|
||||
}
|
||||
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(100);
|
||||
|
||||
isNostrProfileRateLimitedForTest("fresh", now + 60_001);
|
||||
expect(getNostrProfileRateLimitStateSizeForTest()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/channels/nostr/:accountId/profile/import", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
isBlockedHostnameOrIp,
|
||||
readJsonBodyWithLimit,
|
||||
requestBodyErrorToText,
|
||||
@@ -41,30 +42,29 @@ export interface NostrProfileHttpContext {
|
||||
// Rate Limiting
|
||||
// ============================================================================
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
const rateLimitMap = new Map<string, RateLimitEntry>();
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
||||
const RATE_LIMIT_MAX_REQUESTS = 5; // 5 requests per minute
|
||||
const RATE_LIMIT_MAX_TRACKED_KEYS = 2_048;
|
||||
const profileRateLimiter = createFixedWindowRateLimiter({
|
||||
windowMs: RATE_LIMIT_WINDOW_MS,
|
||||
maxRequests: RATE_LIMIT_MAX_REQUESTS,
|
||||
maxTrackedKeys: RATE_LIMIT_MAX_TRACKED_KEYS,
|
||||
});
|
||||
|
||||
export function clearNostrProfileRateLimitStateForTest(): void {
|
||||
profileRateLimiter.clear();
|
||||
}
|
||||
|
||||
export function getNostrProfileRateLimitStateSizeForTest(): number {
|
||||
return profileRateLimiter.size();
|
||||
}
|
||||
|
||||
export function isNostrProfileRateLimitedForTest(accountId: string, nowMs: number): boolean {
|
||||
return profileRateLimiter.isRateLimited(accountId, nowMs);
|
||||
}
|
||||
|
||||
function checkRateLimit(accountId: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(accountId);
|
||||
|
||||
if (!entry || now - entry.windowStart > RATE_LIMIT_WINDOW_MS) {
|
||||
rateLimitMap.set(accountId, { count: 1, windowStart: now });
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return true;
|
||||
return !profileRateLimiter.isRateLimited(accountId);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user