Skip to content

feat(proxy): distinct, non-templated copy per block outcome - #84

Merged
zulfff merged 3 commits into
mainfrom
feat/attacker-page-copy
Sep 29, 2026
Merged

zulfff merged 3 commits into
mainfrom
feat/attacker-page-copy

Conversation

@zulfff

@zulfff zulfff commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

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

  • Per-outcome copy. StopKind classifies by rule id (BAN*, DDoS*/GRPC*, BOT*/JA3*, challenge, else attack); stop_copy() gives each a distinct headline/lead/detail.
    • Ban -> standing decision ("retrying will not change it")
    • Flood -> pacing ("a steady client never reaches this limit")
    • Attack -> per-request fact ("the match is on the request, not on you")
    • Automation -> client-identification fact
    • Challenge -> browser-only check
  • Rate-limit reply is a real page (HTML) or structured JSON, showing the limit and wait actually in force (ddos.per_ip_rate, Retry-After) instead of the bare {"error":"rate_limited"} and the hardcoded X-RateLimit-Limit: 100.
  • Eyebrow badge removed; the outcome is the H1.
  • No raw internal vocabulary (low/medium/high/critical, raw rule ids) reaches the client.
  • One identity motif: a sentinel status line "FortressWAF recorded this as <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 — clean
  • Rendered pages: score_ui 100/A, check_anti_slop 0 findings, no emoji, no em dash
  • Live: SQLi -> "This request carried an attack signature"; sqlmap UA -> "This client identified itself as automation"; per-IP flood -> 429 "This address is sending too fast" with x-ratelimit-limit: 3 (the configured value)

Deliberately not done

  • Status codes unchanged (protocol, not copy).
  • No fabricated counters or scare numbers (antislop R-17/R-36); a repeat-offender count line ships only when the WAF actually holds that count.
  • No honeypot/deception copy (not approved).

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.

FortressWAF Dev 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.
@zulfff
zulfff merged commit 098372b into main Sep 29, 2026
6 checks passed
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.

1 participant