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:
@@ -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)
|
||||
Reference in New Issue
Block a user