fix(security): harden webhook memory guards across channels

This commit is contained in:
Peter Steinberger
2026-03-02 00:11:49 +00:00
parent 1c8ae978d2
commit 43cad8268d
14 changed files with 451 additions and 138 deletions

View File

@@ -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", () => {

View File

@@ -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);
}
// ============================================================================