Follow-up to the decoder DoS fix (7e11115), closing the remaining hardening
items from the same adversarial-review triage.
Bounded template cache (was unbounded):
- Templates now carry a lastUsed timestamp, refreshed whenever one decodes data.
- New exported pruneTemplateCache() evicts templates idle past a TTL then
enforces a hard cap, least-recently-used first. The collector runs it on its
existing 60s map-refresh timer (30 min TTL, 10k entries) — no new timer.
Bounded per-source bookkeeping:
- unknownExporterIds is capped; sources beyond the cap share one overflow
pseudo-exporter instead of allocating a new id each (client attribution
doesn't depend on exporter identity, so only per-exporter stats coarsen).
- Pseudo ids now come from a monotonic counter rather than map size, so they
can't collide after eviction.
UDP ingress guards (the socket is unauthenticated by protocol design):
- Drop datagrams too small to carry a header before doing any parsing work.
- New PacketRateLimiter: fixed-window per-source cap (2000 pkt/s), with the
counter map cleared each window and a cap on distinct sources per window so
spoofed source IPs can't grow it.
- getStats() now reports packetsDropped and templatesCached.
Canonical IPv6 rendering:
- formatIPv6 applies RFC 5952 zero-compression (2001:db8::1, not the fully
expanded form) and unwraps IPv4-mapped addresses (::ffff:192.0.2.1 ->
192.0.2.1) so those flows match client/exporter IPs instead of appearing as
separate IPv6 endpoints.
Adds 7 tests (rate limiter windows//caps/isolation, cache TTL + LRU eviction,
lastUsed refresh, IPv6 canonical + mapped + no-run forms); 126 backend tests pass.
Verified live: flooding the collector with 3000 packets from one source dropped
614 once over the cap while the backend stayed healthy and served the API,
malicious templates were still refused (cache held only the 2 real ones), and
real exporter flows kept decoding right through it (260 -> 336).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
66 lines
2.2 KiB
TypeScript
66 lines
2.2 KiB
TypeScript
// Per-source packet rate limiting for the NetFlow UDP collector.
|
|
//
|
|
// The collector socket is unauthenticated by nature (NetFlow/IPFIX has no auth),
|
|
// so anything able to reach the port can push packets as fast as it likes. Each
|
|
// packet costs template lookups and record parsing on the single Node event
|
|
// loop, so an unthrottled sender can starve the rest of the backend. This caps
|
|
// how much work any one source can force per window and drops the excess.
|
|
//
|
|
// Fixed-window rather than token-bucket: it's cheaper on the hot path, and the
|
|
// counters map is cleared every window so it can't grow without bound. A cap on
|
|
// distinct sources per window bounds it further against spoofed source IPs.
|
|
|
|
export interface RateLimiterOptions {
|
|
/** Length of the counting window in ms. */
|
|
windowMs: number;
|
|
/** Packets allowed per source within a window. */
|
|
maxPerSource: number;
|
|
/** Distinct sources tracked per window; beyond this, new sources are dropped. */
|
|
maxSources: number;
|
|
}
|
|
|
|
export class PacketRateLimiter {
|
|
private counts = new Map<string, number>();
|
|
private windowStart: number;
|
|
private droppedCount = 0;
|
|
|
|
constructor(private readonly opts: RateLimiterOptions, now: number = Date.now()) {
|
|
this.windowStart = now;
|
|
}
|
|
|
|
/**
|
|
* Record a packet from `source`. Returns true if it should be DROPPED.
|
|
*/
|
|
shouldDrop(source: string, now: number = Date.now()): boolean {
|
|
if (now - this.windowStart >= this.opts.windowMs) {
|
|
this.counts.clear(); // bounds the map every window
|
|
this.windowStart = now;
|
|
}
|
|
|
|
const current = this.counts.get(source);
|
|
if (current === undefined && this.counts.size >= this.opts.maxSources) {
|
|
// Too many distinct sources this window (likely spoofed) — drop rather
|
|
// than track, so the map can't be grown arbitrarily.
|
|
this.droppedCount++;
|
|
return true;
|
|
}
|
|
|
|
const next = (current ?? 0) + 1;
|
|
this.counts.set(source, next);
|
|
if (next > this.opts.maxPerSource) {
|
|
this.droppedCount++;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
get dropped(): number {
|
|
return this.droppedCount;
|
|
}
|
|
|
|
/** Number of distinct sources seen in the current window (for tests/stats). */
|
|
get trackedSources(): number {
|
|
return this.counts.size;
|
|
}
|
|
}
|