Hot-swappable protection config, sustained rate limits, and penalty box#19
Conversation
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;
}
}|
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 |
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>
…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>
68a2512 to
3f5aa77
Compare
Devin-Holland
left a comment
There was a problem hiding this comment.
Deep adversarial review — no blockers
Went at this with two independent skeptical code-traces (a manual pass plus a second agent whose only job was to break the atomics). Neither could break refill_and_consume, the penalty-box state machine, the ArcSwap hot-swap, or the monotonic timebase — the token-bucket approximations are all correctly bounded by .min(burst_fp) and documented as such. Really nice work; the two-phase validate→store atomicity, the held-Arc release (Fix 6), and the lazy-projection eviction invariant are all sound.
Verified empirically off the PR head in a worktree:
cargo test --lib→ 78/78 passcargo clippy --all-targets→ only the 10 pre-existing dead-code warnings, none in the new code
Everything inline below is hardening / nits — nothing that should gate the merge. The one I'd most like your read on is the ip_table ↔ penalty-box tension (evict_at), since it's a place where two features in this same PR pull against each other and it's really a design call for you.
One stale note for the description: sni::tests::test_is_grease passes on this branch now (the JA4 fixtures commit brought the fix in), so the "31/32, sole failure pre-existing" line is out of date.
Reviewed by devain (Claude Opus 4.8), on Devin's behalf.
| // A stale deadline left from a prior enabled window must not pin the entry | ||
| // after penaltyBox is disabled; otherwise re-enabling resurrects old deadlines. | ||
| let deadline = state.penalty_deadline_ns.load(Ordering::Relaxed); | ||
| if cfg.penalty_box_duration_ms > 0 && deadline > 0 && now < deadline { |
There was a problem hiding this comment.
[Medium] The penalty box re-opens the unbounded-ip_table growth that this PR's eviction fix is built to close.
evict_at retains any entry while now < deadline, so a boxed IP is pinned for the full durationMs (default 10 min; an absurd/Infinity value saturates to u64::MAX → effectively permanent). Under exactly the IP-diverse flood the eviction fix targets, every attacker that trips a limit gets pinned for the whole penalty window, and there's no absolute cap on ip_table — retention is purely time-based. So the headline eviction fix ("DashMap grew unboundedly per unique source IP") is partly undone by the headline penalty-box feature: entries that would otherwise evict once buckets refill (often < 1 min) now live for the penalty duration, and a ban-forever durationMs makes them immortal.
TCP (no source spoofing) plus the pre-check handshake bound the real rate, so ~10^5 unique attackers is ~10–15 MB — not catastrophic — but the relationship entries ≈ unique-attackers × penalty-window deserves an explicit decision. Worth documenting, and maybe a hard ip_table size cap with pressure-eviction (drop soonest-expiring / oldest) so the protection layer can't itself become the memory-exhaustion vector. Non-blocking — mostly want your call on it.
| let now = now_ns(); | ||
| let burst_fp = cfg.burst_fp(); | ||
| // 5–8: IP-state checks — access IpState once | ||
| let state = self.get_or_create_state(peer_ip, cfg.burst_fp, cfg.sustained_burst_fp); |
There was a problem hiding this comment.
[Low, but a real gap] Eviction-vs-admission race can transiently split per-IP state → concurrency cap (and rate bucket) exceeded.
get_or_create_state inserts and releases the shard lock here; the active increment happens later (fetch_update/fetch_add around L356–364) with no lock held and no await between. The 60s eviction task can fire in that window. A freshly-created entry has active == 0 and a full bucket, so evict_at's retain predicate returns false and removes it before the connection increments it:
- Conn A:
get_or_create_stateinserts E1 (active=0), returnsArc(E1) - Eviction
retain: E1 looks idle → evicts E1 - Conn A: increments E1.active 0→1 →
Allow(E1)(now orphaned) - Conn B (same IP): map empty → inserts fresh E2, increments →
Allow(E2)
→ two admitted conns for one IP with maxConcurrentPerIp: 1; the same mechanism hands B a fresh full bucket (rate limit momentarily reset). Fix 6's held-Arc keeps the decrement correct but doesn't close the admission window. Needs the 60s tick to land inside a few-instruction span, so exploitability is very low — flagging because active_guard_arc_release_survives_eviction evicts after the increment (active=1), so this exact window has no coverage. If you want it airtight: do the concurrency fetch_update while still holding the DashMap Ref, before cloning the Arc out.
| cfg.sustained_burst = s.burst; | ||
| } | ||
| if let Some(pb) = &prot.penalty_box { | ||
| cfg.penalty_box_duration_ms = pb.duration_ms.unwrap_or(600_000.0) as u64; |
There was a problem hiding this comment.
[Low] penaltyBox.durationMs skips the finite/≤0 validation its sibling rate fields got in Fix 3.
parse_protection_config now rejects non-finite / ≤0 connectionsPerSecond/connectionsPerMinute and negative burst, but durationMs is just pb.duration_ms.unwrap_or(600_000.0) as u64. Since NaN as u64 == 0, a durationMs: NaN silently disables the penalty box the operator asked for; a negative value → 0 (also disabled); Infinity → ban-forever. Inconsistent with the sibling fields and the failure mode is silent — suggest the same is_finite() / > 0 guard returning an error.
| let prot_clone = prot.clone(); | ||
| // Fire-and-forget: an in-flight eviction completing during | ||
| // shutdown is harmless (it just holds a DashMap shard lock briefly). | ||
| let _ = tokio::task::spawn_blocking(move || prot_clone.evict()).await; |
There was a problem hiding this comment.
[Low] spawn_blocking moves the eviction work off the reactor, but doesn't stop retain from blocking an admission on the shard it's holding.
The comment reads as "O(N) retain no longer stalls the accept path." It keeps the work off a reactor thread, but DashMap::retain takes a per-shard write lock while check()'s get() needs a read lock on the same shard — so an admission whose IP hashes to the shard currently being retained blocks a reactor thread until that shard's float-math loop finishes. Under the large table this targets, one shard can hold many entries, so there's real residual latency. Consider chunked eviction (or at least softening the comment's claim).
| await sleep(50); | ||
|
|
||
| assert.ok( | ||
| penaltyReasons.includes('penalty_boxed') || penaltyReasons.includes('rate_limited'), |
There was a problem hiding this comment.
[Nit] These integration assertions are too loose to catch a regression.
- L362
penaltyReasons.includes('penalty_boxed') || penaltyReasons.includes('rate_limited')passes even ifpenalty_boxednever fires. - L376
info.penaltyBoxed.includes('127.0.0.1') || info.rateLimited.includes('127.0.0.1')— same OR-escape. - L381
readmits IP after penalty expiresonly checks the TCP connect succeeds, but symphony blocks after accept, so that succeeds regardless of penalty state (the test comment concedes it).
The precise behavior is well covered by the Rust unit tests, and the server.spec.ts none↔some recreate tests are genuinely solid (real TLS round-trip, polls to blocked/unblocked) — so this is cosmetic. Just tighten these three to assert the exact reason / exact IP / an actual proxied response so they'd fail if the feature regressed.
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