feat(haproxy): rate-limit POST /xmlrpc.php floods per client IP

Mirrors the existing wp-login.php brute-force protection. Generic frontend
limits trigger at 300-500 req/s (sized for media-heavy pageloads), but
observed xmlrpc floods run at just a few req/s for hours -- well under that
ceiling while still pinning PHP-FPM workers and driving 503s fleet-wide
(1,011 in one day on a single site).

Adds a dedicated stick-table (xmlrpc_bruteforce, sc2) rather than reusing
wp_bruteforce: sharing a counter would let wp-login and xmlrpc traffic from
the same IP inflate each other's rate. Tarpits at 60 req/min/IP (double
wp-login's 30, since xmlrpc is machine-to-machine and legitimately bursts --
Jetpack sync, mobile app, remote publishing). Honors the same whitelist as
every other rule in the file and does not block the endpoint outright.

Only safe to key on var(txn.real_ip) because of the trusted-proxy header
gate shipped earlier today (2026.08.3) -- before that, per-IP tracking was
trivially evaded via a spoofed X-Forwarded-For.

Adds scripts/test-xmlrpc-rate-limit.py (stdlib unittest, no pytest in this
repo) pinning the tracking rule, the tarpit threshold, the path_end ACL, and
the whitelist exclusions. Existing trusted-proxy-gate, config-rollback, and
cert-write-safety regression suites all still pass unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 14:48:25 -07:00
co-authored by Claude Opus 5
parent 53422c35e8
commit ecc1184533
3 changed files with 186 additions and 0 deletions
+15
View File
@@ -13,4 +13,19 @@ frontend stats
# sc0 connection/rate table so the login-attempt threshold is independent of
# the (much higher) flood thresholds.
backend wp_bruteforce
stick-table type ip size 100k expire 30m store http_req_rate(60s)
# Dedicated stick-table for POST /xmlrpc.php flood tracking.
# Tracked via track-sc2 from the `web` frontend (hap_listener.tpl); counts
# only xmlrpc POSTs per real client IP over a 60s window. This is a SEPARATE
# table/counter from wp_bruteforce (sc1) rather than a shared one: both are
# machine-to-machine WordPress endpoints an attacker could hit from the same
# IP, and sharing a counter would let one endpoint's traffic inflate the
# other's rate -- an IP credential-stuffing wp-login while also flooding
# xmlrpc would trip the wp-login threshold early on xmlrpc volume alone (or
# vice versa). track-sc1 (wp-login) and track-sc2 (xmlrpc) are each gated on
# mutually exclusive path ACLs, so at most one of them ever fires per
# request -- HAProxy's "one track-sc<N> per counter per request" limit is
# never in play here since they're different counters anyway.
backend xmlrpc_bruteforce
stick-table type ip size 100k expire 30m store http_req_rate(60s)