test(security): fix failing tests

- Add CIDR matching to isBlocked() and getBlocklistEntry() methods
- Fix event aggregator threshold logic to only trigger once on first crossing
- Add securityEventAggregator.clearAll() in intrusion-detector tests
- Fix RateLimiter constructor to accept custom maxSize parameter
- Fix token bucket getRetryAfterMs() to return Infinity for impossible requests
- Fix rate limiter peek() to return full capacity for non-existent keys
- Fix shield extractIp() to handle array X-Forwarded-For headers
- Fix ip-manager test mocks to include sync fs methods
- All security tests now passing (173 tests across 8 files)
This commit is contained in:
Ulrich Diedrichsen 2026-01-30 12:09:26 +01:00
parent 8f42141f75
commit b10174ace0
7 changed files with 30 additions and 12 deletions

View File

@ -61,6 +61,9 @@ export class SecurityEventAggregator {
// Filter out events outside the time window
count.events = count.events.filter((e) => new Date(e.timestamp).getTime() > windowStart);
// Store previous count before adding new event
const previousCount = count.events.length;
// Add new event
count.events.push(event);
count.count = count.events.length;
@ -71,8 +74,8 @@ export class SecurityEventAggregator {
count.firstSeen = new Date(count.events[0].timestamp).getTime();
}
// Check if threshold crossed
return count.count >= threshold;
// Return true only when threshold is FIRST crossed (not on subsequent events)
return previousCount < threshold && count.count >= threshold;
}
/**

View File

@ -3,6 +3,7 @@ import { describe, expect, it, beforeEach, vi, afterEach } from "vitest";
import { IntrusionDetector } from "./intrusion-detector.js";
import { SecurityActions, AttackPatterns, type SecurityEvent } from "./events/schema.js";
import { ipManager } from "./ip-manager.js";
import { securityEventAggregator } from "./events/aggregator.js";
vi.mock("./ip-manager.js", () => ({
ipManager: {
@ -15,6 +16,7 @@ describe("IntrusionDetector", () => {
beforeEach(() => {
vi.clearAllMocks();
securityEventAggregator.clearAll(); // Clear event state between tests
detector = new IntrusionDetector({
enabled: true,
patterns: {
@ -313,6 +315,7 @@ describe("IntrusionDetector", () => {
it("should respect custom time windows", () => {
vi.useFakeTimers();
vi.setSystemTime(0); // Start at time 0
const customDetector = new IntrusionDetector({
enabled: true,

View File

@ -3,6 +3,10 @@ import { IpManager } from "./ip-manager.js";
vi.mock("node:fs", () => ({
default: {
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn().mockReturnValue("{}"),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
promises: {
mkdir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined),
@ -265,7 +269,7 @@ describe("IpManager", () => {
durationMs: 86400000,
});
const blocklist = manager.getBlocklist();
const blocklist = manager.getBlockedIps();
expect(blocklist).toHaveLength(2);
expect(blocklist.map((b) => b.ip)).toContain("192.168.1.1");
expect(blocklist.map((b) => b.ip)).toContain("192.168.1.2");
@ -279,7 +283,7 @@ describe("IpManager", () => {
durationMs: 86400000,
});
const blocklist = manager.getBlocklist();
const blocklist = manager.getBlockedIps();
expect(blocklist[0]?.expiresAt).toBeDefined();
expect(new Date(blocklist[0]!.expiresAt).getTime()).toBeGreaterThan(now.getTime());
});
@ -297,7 +301,7 @@ describe("IpManager", () => {
reason: "trusted2",
});
const allowlist = manager.getAllowlist();
const allowlist = manager.getAllowedIps();
expect(allowlist).toHaveLength(2);
expect(allowlist.map((a) => a.ip)).toContain("192.168.1.100");
expect(allowlist.map((a) => a.ip)).toContain("10.0.0.0/8");

View File

@ -170,7 +170,7 @@ export class IpManager {
const now = new Date().toISOString();
for (const entry of this.store.blocklist) {
if (entry.ip === ip && entry.expiresAt > now) {
if (ipMatchesCidr(ip, entry.ip) && entry.expiresAt > now) {
return entry.reason;
}
}
@ -361,7 +361,7 @@ export class IpManager {
*/
getBlocklistEntry(ip: string): BlocklistEntry | null {
const now = new Date().toISOString();
return this.store.blocklist.find((e) => e.ip === ip && e.expiresAt > now) ?? null;
return this.store.blocklist.find((e) => ipMatchesCidr(ip, e.ip) && e.expiresAt > now) ?? null;
}
/**

View File

@ -87,10 +87,11 @@ class LRUCache<K, V> {
* Rate limiter using token bucket algorithm
*/
export class RateLimiter {
private buckets = new LRUCache<string, CacheEntry>(MAX_CACHE_SIZE);
private buckets: LRUCache<string, CacheEntry>;
private cleanupInterval: NodeJS.Timeout | null = null;
constructor() {
constructor(params?: { maxSize?: number }) {
this.buckets = new LRUCache<string, CacheEntry>(params?.maxSize ?? MAX_CACHE_SIZE);
this.startCleanup();
}
@ -122,10 +123,10 @@ export class RateLimiter {
const entry = this.buckets.get(key);
if (!entry) {
// Not rate limited yet
// Not rate limited yet - full capacity available
return {
allowed: true,
remaining: limit.max - 1,
remaining: limit.max,
resetAt: new Date(Date.now() + limit.windowMs),
};
}

View File

@ -432,7 +432,9 @@ export class SecurityShield {
// Try X-Forwarded-For first (if behind proxy)
const forwarded = req.headers["x-forwarded-for"];
if (forwarded) {
const ips = typeof forwarded === "string" ? forwarded.split(",") : forwarded;
// Handle both string and array cases (array can come from some proxies)
const forwardedStr = Array.isArray(forwarded) ? forwarded[0] : forwarded;
const ips = typeof forwardedStr === "string" ? forwardedStr.split(",") : [];
const clientIp = ips[0]?.trim();
if (clientIp) return clientIp;
}

View File

@ -54,6 +54,11 @@ export class TokenBucket {
return 0;
}
// If count exceeds capacity, we can never fulfill this request
if (count > this.config.capacity) {
return Infinity;
}
const tokensNeeded = count - this.tokens;
return Math.ceil(tokensNeeded / this.config.refillRate);
}