Skip to content

Hot-swappable protection config, sustained rate limits, and penalty box#19

Open
kriszyp wants to merge 5 commits into
mainfrom
kris/hot-protection-11
Open

Hot-swappable protection config, sustained rate limits, and penalty box#19
kriszyp wants to merge 5 commits into
mainfrom
kris/hot-protection-11

Conversation

@kriszyp

@kriszyp kriszyp commented Jul 7, 2026

Copy link
Copy Markdown
Member

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).
  • CIDR lists moved inside the existing 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.
  • Standalone server: protection presence is part of listenerSig (none↔some transitions force a seamless recreate so the listener gets the right Option<ProtectionState>); protection contents are excluded so contents-only changes hot-apply without recreating.
  • README hot-swap contradiction fixed; first Rust unit tests for 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 in blockedIps().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_blocking so 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.
  • All protection timing moved from wall-clock to a monotonic timebase — an NTP step can no longer release boxed IPs early or freeze refills.
  • Penalty deadlines use saturating math (a "ban forever" durationMs can't wrap into the past).
  • Active-connection counting is now RAII through the held Arc<IpState> (Decision::Allow carries 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).
  • Hot-path micro-perf: float-derived bucket constants precomputed at config parse instead of per connection.

Review

Cross-model reviewed (Gemini diff-only leg + Opus domain pass, two rounds; all blockers/significant findings fixed in 4abb297 and 0d3571a). 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 in src/protection.rs is welcome.

Testing

  • cargo test: 31/32 — sole failure is sni::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).
  • New coverage: 20+ Rust unit tests (token buckets across config swaps, penalty entry/extend/expiry, eviction retention invariants, overflow), Node specs for updateConfig protection hot-swap, sustained limits, penalty box + hot-swap, and server-level none↔some config-file transition tests.

— Drafted and driven by KrAIs (Claude Opus 4.8 / Fable 5) on Kris's behalf; adversarially reviewed as described above.

🤖 Generated with Claude Code

kriszyp and others added 4 commits July 6, 2026 16:21
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/protection.rs
Comment on lines +137 to +155
// 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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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>
@kriszyp

kriszyp commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

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:

  • Concurrent refill double-counting — the refill CAS caps at burst_fp on every path and exactly one thread wins the last_refill_ns CAS, so over-admission is bounded by burst and self-corrects; this is the documented approximate-bucket design (CLAUDE.md), not a defect.
  • Eviction removing IpState mid-check — only reachable in concurrency-only configs, a nanosecond window against a 60s eviction tick, non-amplifiable, and the Arc-carried release keeps counters correct.

One real finding, fixed in 68a2512 (plus bundled hardening):

  • updateConfig previously swapped routes before validating protection — a combined update with invalid protection left new routes live while protection stayed stale. Routes are now built-and-held until protection validates+parses; both apply all-or-nothing (regression test included).
  • Eviction's penalty retention is now config-gated, so disabling penaltyBox and re-enabling it can't resurrect a pre-disable deadline.
  • NaN/negative rate values now error at parse instead of silently disabling the limit.
  • Penalty deadline writes use fetch_max (monotonic under concurrent writers).

— KrAIs (Claude Opus 4.8 / Fable 5), on Kris's behalf

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hot-swappable protection config (blocklists, rate limits, JA3) without listener restart Support Burst and Sustained Rate Limits Penalty Box

1 participant