From 1b557b9931f584af78046881285883230ee7c510 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 24 Jun 2026 20:30:12 -0700 Subject: [PATCH] feat(waf): wp-login cookie challenge (defeats distributed credential-stuffing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-IP throttle can't see distributed attacks (observed 76k–289k UNIQUE IPs hitting wp-login.php, each low-and-slow). But those bots POST straight to wp-login.php without GETting the form (~15:1 POST:GET on attacked sites). So: hand out a `whplc` cookie on GET of the login form (set-var at request time + http-after-response add-header — request fetches don't evaluate in the response phase) and DENY 403 on login POSTs that lack it. Direct-POST bots are dropped at the edge before reaching PHP; real logins are unaffected (WP login already requires loading the page + cookies). Immediate deny, not tarpit, to avoid connection exhaustion under a 300k-POST flood. Honors the whitelist. Validated locally: GET /wp-login.php emits whplc; other paths don't; config OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSION | 2 +- templates/hap_listener.tpl | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/VERSION b/VERSION index c4cdff4..72c663c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2026.06.2 +2026.06.3 diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index f7a59f0..6ff9709 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -78,6 +78,24 @@ frontend web http-request track-sc1 var(txn.real_ip) table wp_bruteforce if METH_POST wp_login_path http-request tarpit deny_status 429 if METH_POST wp_login_path { sc_http_req_rate(1) gt 30 } !is_local !is_trusted_ip !is_whitelisted + # --- WordPress wp-login.php "must-load-the-form-first" cookie challenge --- + # Defeats DISTRIBUTED credential-stuffing (hundreds of thousands of unique + # IPs, each low-and-slow, so the per-IP rule above can't see them). Such + # bots POST straight to /wp-login.php without ever GETting the form — on + # these sites the login POST:GET ratio is ~15:1. We hand out a cookie when + # the form is actually fetched (GET) and require it on POST; direct-POST + # bots lack it and are denied AT THE EDGE before reaching PHP. Real logins + # are unaffected — WordPress login already requires loading the page and + # accepting cookies. Immediate deny (NOT tarpit) — under a 300k-POST flood, + # holding tarpit connections would exhaust HAProxy. Honors the whitelist. + # Mark login-form GETs at REQUEST time (method/path are reliably evaluable + # here; in the response phase they are not) so the cookie is emitted on the + # form's own response. + http-request set-var(txn.wp_login_form) int(1) if METH_GET wp_login_path + http-after-response add-header set-cookie "whplc=1; Path=/; Max-Age=1800; HttpOnly; Secure; SameSite=Lax" if { var(txn.wp_login_form) -m found } + acl has_login_cookie req.cook(whplc) -m found + http-request deny deny_status 403 if METH_POST wp_login_path !has_login_cookie !is_local !is_trusted_ip !is_whitelisted + # IP blocking using map file (manual blocks only) # Map file format: /etc/haproxy/blocked_ips.map contains " 1" per line # Runtime updates: echo "add map #0 IP_ADDRESS 1" | socat stdio /var/run/haproxy.sock