Repository navigation
feat(proxy): distinct, non-templated copy per block outcome - #84
Merged
Merged
Conversation
added 3 commits
September 29, 2026 21:03
Four resource bugs made the proxy stall when many requests arrived at once (the dashboard became unreachable). All are fixed, each with a regression guard. 1. Upstream client was built per request. send_upstream constructed a fresh hyper_util client for every forwarded request, so there was no connection pooling: each request opened and dropped a TCP connection to the origin. Under load this exhausted ephemeral ports and file descriptors, and further requests blocked indefinitely. Now one pooled client lives on AppState (pool_max_idle_per_host(64)) and is shared by every request. 2. Manager::get() deep-cloned the whole Config on every call. The request path calls it per request; cloning every site, rule, and nested map caused heavy allocation churn and latency. The manager now holds Arc<RwLock<Arc<Config>>> and get() returns a cheap Arc clone. Update is copy-on-write, so readers never see a torn config. 3. The audit log was unbounded and appended non-atomically. It grew without limit under sustained traffic, and its append read-then-wrote under separate locks, so concurrent appends could compute the same sequence number or chain onto a stale prev_hash (corrupting the chain). Append now holds one write lock for the whole operation, and the log is capped (AuditLog::with_cap, default 100k) as a ring that drops the oldest entries; verify_integrity validates the retained window. 4. Upstream calls had no timeout, so a slow origin could pin a task and its connection forever. Request and body reads are now bounded by a 30s tokio timeout. Also fixes a correctness bug: audit entries resolved an empty peer address (they read a SocketAddr extension that was never populated), so the recorded real_ip/host were wrong. The peer address is now threaded into record_request. Verified live: 1000 concurrent proxy requests + 300 blocked + 300 admin health all returned correctly (200/403/200), and the proxy answered a follow-up request in 2.6ms with no stalls. cargo test --workspace: 291 passed, 0 failed; clippy and fmt clean.
The block page and block JSON rendered the client's X-Request-ID header as
the request id. An ordinary browser sends no such header, so the field was
always blank ("id permintaan gak ada"). The value the operator actually
needs is the WAF-generated id, which is also what the audit log records.
block_request_id() now prefers a client-supplied X-Request-ID when present
(for caller correlation) and otherwise falls back to ctx.request_id. The
block page and the JSON body both use it.
Guards: block_page_shows_a_request_id_without_a_client_header,
block_request_id_prefers_the_client_header,
block_request_id_falls_back_to_generated.
Every stop the WAF now serves reads as its own event, not one generic page.
Audited under the antislop rules (findings in anti-slop/audit-001-2026-09-29.md,
fix report in anti-slop/audit-002-2026-09-29.md, all 14 findings fixed).
Direction: "calm sentinel" for attacker-facing pages (dials ENERGY 1 /
RHYTHM 2 / MOTION 1), deliberately separate from the console's "Quiet glass".
Agent-proposed, so these pages are a draft until the owner confirms the
direction.
What changed:
- Per-outcome copy. StopKind classifies a decision by rule id (BAN*, DDoS*/
GRPC*, BOT*/JA3*, challenge, else attack) and stop_copy() gives each its own
headline, lead and detail. A ban reads as a standing decision ("retrying will
not change it"), a flood as pacing, an attack match as a per-request fact, a
scanner as a client-identification fact, the challenge as a browser-only
check.
- The rate-limit reply is a real page (HTML) or structured JSON, with the limit
and wait actually in force (ddos.per_ip_rate, Retry-After), replacing the
bare {"error":"rate_limited"} and the hardcoded X-RateLimit-Limit: 100.
- The eyebrow capsule badge is removed; the outcome is the H1.
- Raw internal vocabulary (low/medium/high/critical, raw rule ids) no longer
reaches the client; it sees plain words and the matched rule's human name.
- One identity motif ties block and challenge together: a sentinel status line
"FortressWAF recorded this as <id>", carrying the id the audit log holds.
Unchanged: HTTP statuses (403/429), the X-FortressWAF-* / Retry-After /
X-RateLimit-* headers, HTML escaping, and the token-spine invariants.
Verified: cargo test --workspace 300 passed, 0 failed; clippy and fmt clean;
rendered pages score 100/A on the deterministic UI gates with no emoji and no
em dash; live SQLi, sqlmap UA and per-IP flood each render their own page, with
x-ratelimit-limit showing the configured 3.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Every stop the WAF serves now reads as its own event, not one generic page. A banned IP, a rate-limited client, a SQLi match, a scanner, and the challenge each get their own copy.
Note: this builds on PR #83 (the hang fix), which added
block_request_id. Merge #83 first, or rebase this onto it.Direction (antislop)
Audited with the antislop skill. Findings:
anti-slop/audit-001-2026-09-29.md(14 findings). Fix report + Delivery Gate:anti-slop/audit-002-2026-09-29.md. All 14 fixed."Calm sentinel" for attacker-facing pages: flat, short, unimpressed, reads as an instrument that already did its job. Dials ENERGY 1 / RHYTHM 2 / MOTION 1. Deliberately separate from the console's "Quiet glass" (the reader is the attacker, not the operator). Agent-proposed, so a draft until the direction is confirmed.
Changes
StopKindclassifies by rule id (BAN*,DDoS*/GRPC*,BOT*/JA3*, challenge, else attack);stop_copy()gives each a distinct headline/lead/detail.ddos.per_ip_rate,Retry-After) instead of the bare{"error":"rate_limited"}and the hardcodedX-RateLimit-Limit: 100.low/medium/high/critical, raw rule ids) reaches the client.<id>" on both block and challenge pages, carrying the id the audit log holds.Unchanged
HTTP statuses (403/429), the
X-FortressWAF-*/Retry-After/X-RateLimit-*headers, HTML escaping, and the token-spine invariants.Verification (measured)
cargo test --workspace --locked— 300 passed, 0 failed (9 new tests)cargo clippy --workspace --all-targets --locked -- -D warnings— clean;cargo fmt --check— cleanscore_ui100/A,check_anti_slop0 findings, no emoji, no em dashsqlmapUA -> "This client identified itself as automation"; per-IP flood -> 429 "This address is sending too fast" withx-ratelimit-limit: 3(the configured value)Deliberately not done
New tests:
each_stop_kind_gets_its_own_copy,block_page_has_no_eyebrow_badge,block_page_does_not_leak_raw_severity,flood_page_states_the_real_limit,challenge_page_shares_the_sentinel_motif,stop_kind_classification.