Merge branch 'feat/xmlrpc-rate-limit'

This commit is contained in:
2026-08-13 14:52:59 -07:00
3 changed files with 186 additions and 0 deletions
+135
View File
@@ -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)
+36
View File
@@ -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
+15
View File
@@ -14,3 +14,18 @@ frontend stats
# 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)