Hot-swappable protection config, sustained rate limits, and penalty box#19
Hot-swappable protection config, sustained rate limits, and penalty box#19kriszyp wants to merge 5 commits into
Conversation
Moves allowlist and blocklist from frozen ProtectionState fields into
ProtectionConfig (inside the existing ArcSwap), making all protection
settings — CIDR lists, JA3 blocklist, rate limits, concurrency cap,
handshake timeout, requireSni — atomically hot-swappable in one pointer
store with zero hot-path overhead.
Extends JsHotConfig / HotConfig with a protection field addressed by
listener port: [{ port, protection }]. Absent entries leave the listener
unchanged; listeners started without protection are skipped silently.
server.ts excludes protection from listenerSig so protection-only config
changes hot-apply rather than forcing a listener recreate.
Existing per-IP token buckets are preserved across a swap; on burst
decrease, tokens cap at the new ceiling on next refill (no underflow
because the consume step reads the post-cap value).
Adds 7 Rust unit tests (config swap changes check() outcome for CIDR,
allowlist, JA3, rate limit tighten/loosen, burst decrease) and 4 Node
integration tests (updateConfig blocklist hot-swap: allowed before,
blockedIps reflects change, blocked event fires, re-removal restores
access). Fixes the false claim in README line ~20 and updates the
"Hot-swapping protection config" and "What can be hot-swapped" sections.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix 1 (BLOCKER): include hasProtection in listenerSig so none→some and some→none protection transitions force a seamless proxy recreate instead of silently no-oping. Contents-only changes still hot-swap without recreating. Documents the invariant in CLAUDE.md, README.md, and type comments. Fix 2 (SIGNIFICANT): restructure update_config to two-phase parse-then-store, matching the existing route update pattern. A malformed CIDR in a later entry no longer tears the update by partially applying earlier stores. Fix 3 (SIGNIFICANT): replace silent skip for unknown/unprotected ports with an error naming all offending ports in one message, collected before any store so it composes with fix 2's atomicity guarantee. Fix 4 (SIGNIFICANT): detect same-port ambiguity (multiple listeners on one port) in the validation phase and return an error directing the caller to restart- configure instead. Documents the limitation rather than adding an address field to keep the API surface minimal. Fix 5 (SUGGESTION): replace Vec::contains O(n²) dedup in blocked_ips() with HashSet insertion; also removes per-string re-allocation in the blocklist loop. Fix 6 (SUGGESTION): drop the erroneous `&& burst_fp > 0` conjunct from the concurrency-limited reporting path in blocked_ips(). An IP at its concurrency limit must be reported even when no rate limit is configured. Adds a unit test. Fix 7 (COVERAGE): add symphony-server (protection hot-swap via config file) describe block to server.spec.ts, covering none→some (forces recreate, starts blocking 127.0.0.1/32) and some→none (forces recreate, traffic admitted again). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…sues #4, #1) Sustained rate limit (issue #4): - Add a second per-minute token bucket (sustained_tokens, sustained_last_refill_ns) to IpState alongside the existing per-second bucket. Both use the ×1000 AtomicU32 fixed-point CAS idiom. Both are checked on admission; exhausting either blocks. - Config: ProtectionConfig.sustained_cpm / sustained_burst (Rust), ProtectionConfig.sustained (TS: { connectionsPerMinute, burst? }). - blocked_ips() now checks both buckets; sustained exhaustion appears in rateLimited. - Max sustained burst: 4,294,967 connections (u32::MAX / 1000) — documented. Penalty box (issue #1): - Add penalty_deadline_ns: AtomicU64 to IpState (0 = not penalized). - Exhausting any rate limit sets deadline = now_ns() + penalty_box_duration_ms * 1e6. - While penalized: debit both buckets (measures continued excess); if either exhausts while boxed, deadline is reset to now + penalty_ms (extension from now). If the IP stops attacking, buckets refill, debit succeeds, deadline is not extended. - New BlockReason::PenaltyBoxed (reason string "penalty_boxed") surfaced in blocked events and in a new penaltyBoxed field in blocked_ips() / JsBlockedIpsInfo. - Config: ProtectionConfig.penalty_box_duration_ms (Rust), penaltyBox: { durationMs? } (TS, default 600000 ms = 10 min). Absent = feature off. - Penalty state lives on IpState and survives config hot-swaps. Eviction fix: - ProtectionState::evict() was dead code. Now spawned in start() (one task per protected listener) on a 60 s interval, cancelled via shutdown_tx broadcast. - Refactored to evict_at(now_ns) for testability; public evict() calls evict_at(now_ns()). - Uses lazy bucket projection: computes projected refill from (now - last_refill_ns) rather than the stale stored token value. Retains entries where either bucket would not yet be fully refilled — prevents attackers from resetting their sustained window by pausing until eviction. Also retains penalty-boxed and active entries. - Refactored refill+consume into shared refill_and_consume() helper to DRY both buckets. - Tests also use check_at(now: u64) for controllable-clock unit tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ction correctness, reporting accuracy, and hot-path perf
Fix 1 (overflow): penalty deadline computation at 3 sites now uses
saturating_add(penalty_ms.saturating_mul(1_000_000)) so an absurdly large
durationMs ("ban forever") saturates to u64::MAX rather than wrapping to a
past deadline that silently never engages the box.
Fix 2 (monotonic clock): now_ns() replaced with a process-wide Instant anchor
(OnceLock<Instant> + Instant::now().duration_since). NTP forward steps no
longer release penalty-boxed IPs early; backward steps no longer freeze bucket
refills. Values are only compared internally so the unix-epoch offset is not
needed. check_at test seam is unaffected.
Fix 3 (eviction off reactor): evict() inside select! now runs via
tokio::task::spawn_blocking so the O(N) DashMap::retain (shard locks + float
math) does not stall the accept path under diverse-IP flood.
Fix 4 (blockedIps penalty gate): blocked_ips() only populates penaltyBoxed
when cfg.penalty_box_duration_ms > 0. After a hot-swap that disables penaltyBox,
stale deadlines on IpState entries no longer appear in the reporting API.
Fix 5 (blockedIps projection): rate-limited reporting now applies the same
lazy elapsed-refill projection as evict_at, so an idle IP that has fully
recovered is not falsely listed as still limited.
Fix 6 (evict vs active-counter race): check() now returns Decision::Allow(Arc<IpState>)
carrying the held Arc. ActiveGuard in proxy_conn.rs stores it and calls
state.release() through it on drop, so decrement always hits the same IpState
even when eviction removes the entry from ip_table between admission and close.
ProtectionState::release(peer_ip) retained for #[cfg(test)] use only.
Fix 7 (hot-path precompute): ProtectionConfig::precompute() caches burst_fp,
tokens_per_ns, sustained_burst_fp, sustained_tokens_per_ns as plain fields.
check() reads fields instead of calling f64 division methods per connection.
Fix 8 (doc): README penalty-box section notes that a durationMs hot-swap
leaves already-stamped deadlines on the old duration until expiry/re-stamp.
CLAUDE.md design section updated with monotonic clock rationale.
New tests: penalty_box_absurd_duration_still_engages (fix 1),
blocked_ips_penalty_boxed_gated_on_config (fix 4),
blocked_ips_rate_limited_excludes_recovered_ips (fix 5),
active_guard_arc_release_survives_eviction (fix 6).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request enhances the proxy's protection layer by introducing sustained (per-minute) rate limits, a penalty box mechanism to temporarily block offending IPs, and hot-swappable per-listener protection configurations. It also switches to a monotonic clock for timing, implements lazy bucket projection for IP state eviction, and refactors connection tracking to prevent active-counter decrement races. A critical concurrency issue was identified in the refill_and_consume function, where multiple threads could concurrently calculate the same elapsed time and double-refill the token bucket before the refill timestamp is updated. It is recommended to update the refill timestamp first using compare_exchange to claim the elapsed time window.
| // Refill phase — caps at burst_fp to handle burst decreases without underflow. | ||
| loop { | ||
| let last = last_refill_ns.load(Ordering::Relaxed); | ||
| let elapsed = now.saturating_sub(last); | ||
| let refill = ((elapsed as f64) * rate_per_ns * 1000.0) as u32; | ||
| if refill == 0 { | ||
| break; | ||
| } | ||
| let old_tokens = tokens.load(Ordering::Relaxed); | ||
| let new_tokens = old_tokens.saturating_add(refill).min(burst_fp); | ||
| if tokens | ||
| .compare_exchange(old_tokens, new_tokens, Ordering::Relaxed, Ordering::Relaxed) | ||
| .is_ok() | ||
| { | ||
| // Only the first CAS winner advances the refill timestamp; losers retry from above. | ||
| let _ = last_refill_ns.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed); | ||
| break; | ||
| } | ||
| } |
There was a problem hiding this comment.
There is a race condition in refill_and_consume under high concurrency. If multiple threads concurrently call refill_and_consume before the winning thread updates last_refill_ns, they will all see the old last_refill_ns timestamp, calculate the same elapsed time, and repeatedly add the refill tokens to the bucket. This leads to a double-refill (or multi-refill) bug, allowing clients to bypass the rate limit during concurrent floods.
To fix this, we should update last_refill_ns first using compare_exchange to "claim" the elapsed time before updating the token count. This ensures only one thread can successfully apply the refill for any given elapsed time window.
// Refill phase — caps at burst_fp to handle burst decreases without underflow.
loop {
let last = last_refill_ns.load(Ordering::Relaxed);
let elapsed = now.saturating_sub(last);
let refill = ((elapsed as f64) * rate_per_ns * 1000.0) as u32;
if refill == 0 {
break;
}
// Advance the refill timestamp first to claim the elapsed time and prevent double-refill under concurrency.
if last_refill_ns
.compare_exchange(last, now, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
loop {
let old_tokens = tokens.load(Ordering::Relaxed);
let new_tokens = old_tokens.saturating_add(refill).min(burst_fp);
if tokens
.compare_exchange(old_tokens, new_tokens, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
break;
}
}
break;
}
}…idation, monotonic deadline writes Fix 1 (real defect): update_config now builds the new route table and validates+parses all protection updates before applying either. A combined call with valid routes + invalid protection (bad port, bad CIDR, non-finite rate) no longer leaves routes in a half-applied state. Regression test: combined update with valid routes + port-9999 protection must throw and leave the old route serving. Fix 2 (hardening): evict_at now gates the "keep: in penalty box" retain condition on cfg.penalty_box_duration_ms > 0. Previously a stale deadline on an IpState entry survived a penaltyBox-disable hot-swap and kept the entry alive; re-enabling the config could then resurrect the pre-disable deadline. With the fix, disabled → entries evict normally → re-enable starts fresh. Unit test: box an IP, disable penaltyBox, evict at far future, re-enable — IP admits fresh. Fix 3 (hardening): parse_protection_config now returns an error for non-finite or <= 0 connectionsPerSecond / connectionsPerMinute, and for non-finite or < 0 burst (absent burst is still accepted). Previously NaN/-1 silently disabled the rate limit. Unit tests for NaN and -1 on all four fields. Fix 4 (nit): penalty_deadline_ns writes now use fetch_max instead of store so concurrent writers with slightly older now_ns values cannot regress a deadline set by a later writer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Codex backfill review completed (the Codex leg was quota-blocked when this PR opened; the description's review caveat is now resolved). Outcome, so reviewers don't re-derive it: Both Codex "blockers" were refuted on code-trace:
One real finding, fixed in 68a2512 (plus bundled hardening):
— KrAIs (Claude Opus 4.8 / Fable 5), on Kris's behalf 🤖 Generated with Claude Code |
Closes #11, closes #4, closes #1.
What this does
Three protection features that build on each other, reviewed and folded into one PR:
Hot-swappable protection config (#11)
Protection config (per-IP rate limits, concurrency caps, CIDR allow/blocklists, JA3 blocklist, handshake timeout, SNI enforcement) previously required a listener restart to change — the biggest operational gap for incident response, and a prerequisite for the planned Harper-WAF → Symphony blocklist feed.
updateConfig({ protection: [{ port, protection }] })— per-listener updates, applied atomically (validate-all → parse-all → store-all; errors name every offending port, nothing tears on a malformed CIDR).ArcSwap<ProtectionConfig>, so one atomic pointer store swaps the whole snapshot;check()already loads the config per admission, so swaps reach live listeners with zero hot-path cost.listenerSig(none↔some transitions force a seamless recreate so the listener gets the rightOption<ProtectionState>); protection contents are excluded so contents-only changes hot-apply without recreating.protection.rs.Burst + sustained rate limits (#4)
A second, independent per-IP token bucket over a per-minute window (
sustained: { connectionsPerMinute, burst? }) beside the per-second bucket. Both are checked on admission; exhausting either blocks. Same lock-free ×1000 fixed-point AtomicU32 CAS idiom.Penalty box (#1)
On any rate-limit exhaustion, the IP is boxed for
penaltyBox.durationMs(default 10 min). Attempts while boxed are blocked outright but still debit the buckets, so continued excess — actual bucket exhaustion, not mere attempts — extends the penalty (reset to full duration from now). Expires naturally once the source quiets. Boxed IPs surface inblockedIps().penaltyBoxed.Also fixed along the way
ProtectionState::evict()was dead code — nothing ever spawned it, so the per-IP DashMap grew unboundedly per unique source IP (exactly under IP-diverse attack). Now a periodic task (60s,spawn_blockingso the O(N) retain never stalls the accept path), with lazy bucket projection so an attacker can't reset their sustained window by pausing until eviction.durationMscan't wrap into the past).Arc<IpState>(Decision::Allowcarries it), structurally eliminating an eviction/decrement race.blockedIps(): HashSet dedup, elapsed-refill projection (no stale over-reporting), penaltyBoxed gated on current config, and concurrency-limited IPs now reported even when no rate limit is configured (pre-existing bug).Review
Cross-model reviewed (Gemini diff-only leg + Opus domain pass, two rounds; all blockers/significant findings fixed in
4abb297and0d3571a). Caveat: the Codex leg was unavailable both attempts (workspace spend cap), so there is no second outside-model code-trace — extra reviewer attention on the atomics insrc/protection.rsis welcome.Testing
cargo test: 31/32 — sole failure issni::tests::test_is_grease, pre-existing on main, fixed in Fix CI: check out core submodule, drop retired macos-13 runner #18.npm test: 46/47 — sole failure is the pre-existing hardcoded'0.4.0'version assertion, fixed in Fix CI: check out core submodule, drop retired macos-13 runner #18.cargo clippy --all-targets: no new items vs main (main's 10 pre-existing errors are tracked separately).— Drafted and driven by KrAIs (Claude Opus 4.8 / Fable 5) on Kris's behalf; adversarially reviewed as described above.
🤖 Generated with Claude Code