Files

136 lines
5.2 KiB
Python
Raw Permalink Normal View History

#!/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)