feat(waf): wp-login cookie challenge (defeats distributed credential-stuffing)
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 1m29s

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 20:30:12 -07:00
parent 6ced2f8797
commit 1b557b9931
2 changed files with 19 additions and 1 deletions
+1 -1
View File
@@ -1 +1 @@
2026.06.2
2026.06.3
+18
View File
@@ -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 "<ip_or_cidr> 1" per line
# Runtime updates: echo "add map #0 IP_ADDRESS 1" | socat stdio /var/run/haproxy.sock