Production hardening
Phase 9 adds a three-layer rate limiter that composes with the existing replay defences. The cryptographic substrate (Bloom / sliding window / response chain) catches token reuse; the rate limiter catches floods of novel tokens that the cryptographic layer happily accepts as fresh.
The three layers
| Layer | Fires | Key | Purpose | Audit emit |
|---|---|---|---|---|
| Per-source-IP | Before header parse | resolved client IP | DDoS prevention; unknown-fleet floods rejected at cheapest layer | Skipped (DDoS fast-path) |
| Per-fleet | After fleet resolution, before verify | fleet_id | DDoS prevention; single-fleet flood before verify CPU burns | Skipped (DDoS fast-path) |
| Per-(fleet, sub-ID) | After verify + range check | fleet_id:sub_id | Forensic abuse detection; compromised credential being burned | Emitted at Tier ≥ Errors with reason sub-id-rate-limited |
The asymmetric audit treatment is intentional: the two DDoS- prevention layers bypass the audit emitter (ring-buffer back- pressure under sustained flood is exactly the failure mode they prevent); the forensic layer emits because operators need “credential X is being abused” in the audit log, and the per- sub-ID throughput is by definition low enough that emitting per throttle isn’t a back-pressure surface.
Configuration
Each layer is configured independently via env. The boot default
is unlimited() — short-circuits to LimitOutcome::Allowed
with zero map allocation, so deployments don’t see a hot-path
regression on upgrade.
Per-source-IP
| Env var | Default | Meaning |
|---|---|---|
EDSSA_RATELIMIT_SOURCE_IP_REFILL_PER_SEC | (unset) | Tokens/sec per IP; absent = unlimited |
EDSSA_RATELIMIT_SOURCE_IP_BURST | 50 | Burst capacity per IP |
EDSSA_RATELIMIT_SOURCE_IP_MAX_KEYS | 65536 | Hard cap on stored IP buckets (LRU; fail-open at cap) |
Per-fleet (global default)
| Env var | Default | Meaning |
|---|---|---|
EDSSA_RATELIMIT_REFILL_PER_SEC | (unset) | Tokens/sec per fleet; absent = unlimited |
EDSSA_RATELIMIT_BURST | 200 | Burst capacity per fleet |
EDSSA_RATELIMIT_MAX_KEYS | 4096 | Hard cap on stored fleet buckets |
Per-fleet overrides land in the manifest (see below).
Per-(fleet, sub-ID)
| Env var | Default | Meaning |
|---|---|---|
EDSSA_RATELIMIT_SUB_ID_REFILL_PER_SEC | (unset) | Tokens/sec per (fleet, sub-ID); absent = unlimited |
EDSSA_RATELIMIT_SUB_ID_BURST | 20 | Burst capacity per (fleet, sub-ID) |
EDSSA_RATELIMIT_SUB_ID_MAX_KEYS | 32768 | Hard cap on stored (fleet, sub-ID) buckets |
Per-fleet manifest override
Manifest gains optional per-fleet rate-limit fields. Both must be set together (or both omitted); partial overrides are rejected at boot.
[[fleet]]
id = "prod-hft"
seed_path = "seeds/prod-hft.seed"
preset = "high-security"
rate_limit_burst = 50 # tighter than the global default
rate_limit_refill_per_sec = 25.0
SIGHUP-reload applies the new policy on the next request. The
bucket’s accumulated (tokens, last_refill) carry forward across
the policy change — refill uses the new rate; tokens clamp to
the new burst. This is the right semantics for live tuning:
operators don’t want a “reset” on every policy bump.
XFF chain resolver
The per-source-IP limiter needs the real client IP, not the
upstream proxy’s. In production the proxy sits behind one or more
layers (Caddy / Cloudflare / a cloud LB); the TCP peer IP is the
upstream’s, and the real client IP is in X-Forwarded-For.
Trust list
EDSSA_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8,2001:db8::/32,fc00::/7
Comma-separated mix of bare IPs and CIDR ranges (IPv4 + IPv6). Empty trust list (the boot default) disables XFF parsing entirely — operators behind a real upstream MUST set this; otherwise the limiter throttles by upstream IP only. This is the loud-failure-safe default: aggressive throttling on the upstream fires immediately and operators fix the env var.
Algorithm
- Read
X-Forwarded-For: ip1, ip2, ip3, …. - If trust list is empty → return peer IP (XFF parsing disabled).
- If peer IP is not in the trust list → return peer IP. The request didn’t traverse a trusted proxy, so XFF is meaningless. Defends against an attacker speaking directly to the proxy with a spoofed XFF header.
- Walk XFF rightmost-first; drop trusted hops; first untrusted entry is the client.
- Malformed XFF entry → bail to peer IP. Attacker-injected garbage never silently uses a stale chain entry.
- Every XFF entry trusted → log warn + return leftmost as best-effort (operator’s trust list covers the client’s network; the warn is observable).
Why rightmost-first walk is safe against spoofing
X-Forwarded-For is a hop-by-hop header set by upstream proxies.
An attacker speaking directly to the proxy with X-Forwarded-For: 1.2.3.4 has the peer IP appended after the spoofed value. The
rightmost-first walk reaches the peer (untrusted, since the
attacker isn’t in the trust list) and returns peer — never the
spoofed value.
The only way to forge a client IP is to be a trusted proxy yourself, which is the operator’s security boundary by definition.
Reject responses
All three layers return:
- HTTP 429 Too Many Requests (not 401).
Retry-After: <seconds>header. Rounded up to 1 second minimum so clients don’t hot-loop. Capped atu32::MAXso the header value stays sane even for never-refill buckets (refill_per_sec = 0).- Empty body (ADR-006).
The per-source-IP + per-fleet layers do NOT call the audit emitter; the per-(fleet, sub-ID) layer DOES (security-relevant). All three increment Prometheus counters regardless.
Observability
Counters
| Metric | Labels | When |
|---|---|---|
edssa_source_ip_throttled_total | scope (always "source-ip") | per-source-IP 429 |
edssa_ratelimit_throttled_total | fleet | per-fleet 429 |
edssa_sub_id_throttled_total | fleet (sub-ID NOT a label; SUB_ID_MAX cardinality would explode Prometheus) | per-(fleet, sub-ID) 429 |
Gauges
| Metric | Labels | Sampled |
|---|---|---|
edssa_ratelimit_key_count | scope (fleet / source-ip / sub-id) | 1 Hz via tokio task |
edssa_ratelimit_max_keys | scope | once at boot (constant) |
Alert on edssa_ratelimit_key_count{scope=...} / edssa_ratelimit_max_keys{scope=...} > 0.8
to spot when an LRU cap is being approached. Fail-open semantics
mean the limiter doesn’t block traffic at cap — but operators
should bump the cap before the unobserved tail starts breaking
isolation.
Existing DDoS fast-path
The pre-Phase-9 reject paths for malformed headers
(missing-header / malformed-header / wrong-length) already
bypass the audit emitter — they increment the
edssa_rejects_total{reason} counter only. The new 429 rate-
limit reject paths follow the same discipline. Operators auditing
the hot path can confirm: the audit emitter (audit_sink.emit)
is only reached after fleet resolution succeeds AND the
rate-limit gates pass.
Operator runbook
Tuning checklist
- Boot with unlimited (the default). Measure baseline traffic for a week.
- Set per-source-IP first — the cheapest layer, easiest to
over-tighten. Start at 5× peak observed QPS per IP; alert on
edssa_source_ip_throttled_totalrate > 0 for legitimate clients. - Set per-fleet next — global default; per-fleet manifest override for outliers. Tight bucket for HFT-grade fleets; loose for public-API fleets.
- Set per-sub-ID last — the abuse-detection layer. Start
permissive; tighten when audit-log shows
sub-id-rate-limitedrejects correlated with suspicious sub-IDs. - Configure
EDSSA_TRUSTED_PROXIESthe moment the proxy sits behind any upstream. Without it, per-source-IP throttles by upstream IP only (every client looks like one IP).
Suspected attack response
edssa_source_ip_throttled_total{scope="source-ip"}spike alone → DDoS attempt; the per-source-IP layer is doing its job. Confirm peer IPs via tracing logs.edssa_ratelimit_throttled_total{fleet=...}spike → legitimate-looking traffic to one fleet. Check whether the fleet’s seed was rotated recently (legitimate spike) or if a credential has leaked.edssa_sub_id_throttled_total{fleet=...}+ audit-logsub-id-rate-limitedrejects → forensic signal that a specific credential is being abused. Rotate the seed (F-25 cooperative recovery) immediately; investigate the audit trail for the affected sub-ID.
What’s NOT in this batch
- Per-fleet rate-limit panel UI. Today operators edit
fleets.tomldirectly and SIGHUP. A future batch lands a/fleets/:id/rate-limitform posting to a SIGHUP-trigger flow. - Audit Tier-3 trace surfacing rate-limit state. The bucket’s
(tokens, last_refill)snapshot at emit time would let forensic analysis replay the throttle decision. - Criterion bench suite over the limiter hot path under simulated 10k QPS — operator capacity planning needs concrete numbers; lands as a focused perf-pass batch.