From ecc118453352a8d710c37d7b1529d3a95e2d600b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 13 Aug 2026 14:48:25 -0700 Subject: [PATCH] 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) --- scripts/test-xmlrpc-rate-limit.py | 135 ++++++++++++++++++++++++++++++ templates/hap_listener.tpl | 36 ++++++++ templates/hap_security_tables.tpl | 15 ++++ 3 files changed, 186 insertions(+) create mode 100644 scripts/test-xmlrpc-rate-limit.py diff --git a/scripts/test-xmlrpc-rate-limit.py b/scripts/test-xmlrpc-rate-limit.py new file mode 100644 index 0000000..cf44e8d --- /dev/null +++ b/scripts/test-xmlrpc-rate-limit.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Regression tests for per-client-IP rate limiting on POST /xmlrpc.php. + +Why this file exists +-------------------- +POST /xmlrpc.php floods were unthrottled fleet-wide. The generic frontend +rate limits (hap_listener.tpl) trigger at 3000/5000 req/10s -- i.e. 300-500 +req/s -- but the observed floods run at a few req/s for hours, well under +that ceiling. The existing wp_bruteforce mechanism (dedicated stick-table, +60s window, per real client IP) solves exactly this shape of problem for +POST /wp-login.php; this change adds an equivalent dedicated table/rule pair +for POST /xmlrpc.php. + +This is only safe to key on var(txn.real_ip) because of the trusted-proxy +gate added earlier (release 2026.08.3, see test-trusted-proxy-gate.py) -- +before that fix, a direct client could spoof any client IP via +X-Forwarded-For and evade all per-IP tracking. + +These tests pin: + - a dedicated stick-table for xmlrpc tracking exists in + hap_security_tables.tpl (own sc slot / own counter -- not sharing the + wp_bruteforce counter, so a wp-login brute-force run and an xmlrpc flood + from the same IP don't inflate each other's rate) + - the tracking rule only fires on POST /xmlrpc.php (path_end, so + subdirectory WP installs are covered) + - the limiting rule tarpits over the chosen threshold + - the limiting rule honors the same whitelist as every other rule in the + file (!is_local !is_trusted_ip !is_whitelisted) + - xmlrpc is not blocked outright -- only the rate-limit ACL is present, + there's no blanket deny of the path + +Running +------- + python3 scripts/test-xmlrpc-rate-limit.py +""" + +import os +import re +import sys +import logging +import tempfile +import unittest + +MODULE_DIR = os.path.abspath( + os.environ.get('HAPROXY_MANAGER_DIR', + os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) +) +os.chdir(MODULE_DIR) +sys.path.insert(0, MODULE_DIR) + +_LOG_DIR = tempfile.mkdtemp(prefix='haproxy-mgr-test-logs-') +_real_file_handler = logging.FileHandler +logging.FileHandler = ( + lambda filename, *a, **kw: _real_file_handler( + os.path.join(_LOG_DIR, os.path.basename(filename)), *a, **kw) +) + +import haproxy_manager # noqa: E402 + + +def render_listener(): + return haproxy_manager.template_env.get_template('hap_listener.tpl').render( + crt_path='/etc/haproxy/certs', + suspension_enabled=False, + coraza_spoe_backend=None, + ) + + +def render_security_tables(): + return haproxy_manager.template_env.get_template( + 'hap_security_tables.tpl').render() + + +class XmlrpcRateLimit(unittest.TestCase): + + def setUp(self): + self.listener_cfg = render_listener() + self.tables_cfg = render_security_tables() + + def test_dedicated_stick_table_defined(self): + """A dedicated table (not wp_bruteforce) tracks xmlrpc requests, so a + wp-login brute-force run and an xmlrpc flood from the same IP can't + inflate each other's rate counter.""" + self.assertRegex( + self.tables_cfg, + r'backend\s+xmlrpc_bruteforce\s*\n\s*stick-table\s+type\s+ip\b.*store.*http_req_rate', + ) + # Must not be the same table wp-login already uses. + self.assertNotIn('backend wp_bruteforce\n stick-table type ip size 100k expire 30m store http_req_rate(60s)\nbackend xmlrpc_bruteforce', self.tables_cfg) + + def test_xmlrpc_path_acl_uses_path_end(self): + """path_end (not path_beg) so subdirectory WP installs are covered, + matching the wp-login rule's reasoning.""" + self.assertRegex( + self.listener_cfg, + r'acl\s+xmlrpc_path\s+path_end\s+/xmlrpc\.php', + ) + + def test_tracking_rule_only_fires_on_post_xmlrpc(self): + self.assertRegex( + self.listener_cfg, + r'http-request\s+track-sc2\s+var\(txn\.real_ip\)\s+table\s+xmlrpc_bruteforce\s+if\s+METH_POST\s+xmlrpc_path', + ) + + def test_limiting_rule_tarpits_over_threshold_with_whitelist(self): + pattern = ( + r'http-request\s+tarpit\s+deny_status\s+429\s+if\s+METH_POST\s+xmlrpc_path\s+' + r'\{\s*sc_http_req_rate\(2\)\s+gt\s+(\d+)\s*\}\s+' + r'!is_local\s+!is_trusted_ip\s+!is_whitelisted' + ) + match = re.search(pattern, self.listener_cfg) + self.assertIsNotNone( + match, 'expected a tarpit rule tracking sc2 with the full whitelist') + threshold = int(match.group(1)) + self.assertGreater(threshold, 0) + + def test_xmlrpc_not_blocked_outright(self): + """The endpoint must remain functional for clients under the + threshold -- only a rate-limit ACL, no blanket deny of the path.""" + self.assertNotRegex( + self.listener_cfg, + r'http-request\s+deny\s+deny_status\s+\d+\s+if\s+(?:METH_POST\s+)?xmlrpc_path\s*(?:!is_local|\n)', + ) + + def test_rule_order_after_wp_login_block(self): + """Not load-bearing for correctness (mutually exclusive paths), but + keep the new block grouped with the other WordPress-specific rules + rather than scattered elsewhere in the file.""" + wp_login_idx = self.listener_cfg.index('wp_login_path') + xmlrpc_idx = self.listener_cfg.index('xmlrpc_path') + self.assertLess(wp_login_idx, xmlrpc_idx) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index 6a9ff27..4ed87ae 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -118,6 +118,42 @@ frontend web 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 + # --- WordPress xmlrpc.php flood protection --- + # xmlrpc.php floods are a common, sustained abuse pattern that the generic + # limits above don't catch: those trigger at 3000/5000 req/10s (300-500 + # req/s, sized for media-heavy pageloads), while observed xmlrpc floods run + # at just a few req/s for hours -- comfortably under that ceiling but still + # enough to pin PHP-FPM workers and show up as 503s for the rest of the + # site. Same shape of problem as wp-login credential stuffing, so it gets + # the same fix: track POSTs to xmlrpc.php per real client IP in a DEDICATED + # 60s table (sc2 / backend xmlrpc_bruteforce, defined in + # hap_security_tables.tpl -- kept separate from wp_bruteforce so the two + # endpoints' traffic can't inflate each other's counter, see that file for + # the reasoning) and tarpit once an IP exceeds the threshold. + # + # Threshold is 60/min (double wp-login's 30/min), not because xmlrpc abuse + # is less severe but because legitimate traffic here is machine-to-machine + # rather than a human filling out a form: Jetpack sync, the WordPress + # mobile app, and remote-publishing clients (e.g. an offline blog editor) + # can legitimately burst several xmlrpc calls in quick succession. 60/min + # (1 req/s average over the window) comfortably absorbs that burst while + # still tripping well before an hours-long few-req/s flood does real + # damage -- at 2 req/s sustained the 60s counter clears the threshold in + # under a minute. + # + # Tarpit (not deny), matching the wp-login rule: this is per-IP tracking + # of a bounded set of offenders, not the wp-login cookie challenge's + # distributed hundreds-of-thousands-of-IPs scenario where holding + # connections would exhaust HAProxy itself, so tying up the flooding IP's + # connections is the cheaper and more effective response. path_end (not + # path_beg) covers subdirectory WP installs, same reasoning as wp-login. + # Honors the same whitelist (RFC1918 / trusted_ips.list / trusted_ips.map) + # so health checks and trusted infrastructure are unaffected, and legit + # clients under the threshold are never blocked outright. + acl xmlrpc_path path_end /xmlrpc.php + http-request track-sc2 var(txn.real_ip) table xmlrpc_bruteforce if METH_POST xmlrpc_path + http-request tarpit deny_status 429 if METH_POST xmlrpc_path { sc_http_req_rate(2) gt 60 } !is_local !is_trusted_ip !is_whitelisted + # WordPress REST batch endpoint lockdown ("wp2shell": CVE-2026-63030 + # CVE-2026-60137). Chaining a core SQL injection with REST batch-route # confusion gives unauthenticated RCE on WP 6.9.0-6.9.4 and 7.0.0-7.0.1 diff --git a/templates/hap_security_tables.tpl b/templates/hap_security_tables.tpl index d8d4731..017fc2c 100644 --- a/templates/hap_security_tables.tpl +++ b/templates/hap_security_tables.tpl @@ -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 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) \ No newline at end of file