Merge branch 'feat/xmlrpc-rate-limit'
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)
|
||||||
@@ -118,6 +118,42 @@ frontend web
|
|||||||
acl has_login_cookie req.cook(whplc) -m found
|
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
|
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 +
|
# WordPress REST batch endpoint lockdown ("wp2shell": CVE-2026-63030 +
|
||||||
# CVE-2026-60137). Chaining a core SQL injection with REST batch-route
|
# 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
|
# confusion gives unauthenticated RCE on WP 6.9.0-6.9.4 and 7.0.0-7.0.1
|
||||||
|
|||||||
@@ -13,4 +13,19 @@ frontend stats
|
|||||||
# sc0 connection/rate table so the login-attempt threshold is independent of
|
# sc0 connection/rate table so the login-attempt threshold is independent of
|
||||||
# the (much higher) flood thresholds.
|
# the (much higher) flood thresholds.
|
||||||
backend wp_bruteforce
|
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)
|
stick-table type ip size 100k expire 30m store http_req_rate(60s)
|
||||||
Reference in New Issue
Block a user