From 704be388825a496cec6543c1e4997547fb0abedd Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 14 Aug 2026 07:45:52 -0700 Subject: [PATCH] feat(haproxy): gate unauthenticated wp-admin requests at the edge Redirect /wp-admin/* to the site's login page when no wordpress_logged_in_ cookie is present, so unauthenticated requests never boot PHP. Identity-based rather than rate-based, so it is unaffected by how widely an attack is distributed. Allowlists the paths that legitimately serve unauthenticated visitors, including the css/js the login page itself loads. --- scripts/test-wpadmin-gate.py | 111 +++++++++++++++++++++++++++++++++++ templates/hap_listener.tpl | 72 +++++++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 scripts/test-wpadmin-gate.py diff --git a/scripts/test-wpadmin-gate.py b/scripts/test-wpadmin-gate.py new file mode 100644 index 0000000..8cef3e4 --- /dev/null +++ b/scripts/test-wpadmin-gate.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Regression tests for the WordPress admin edge gate in hap_listener.tpl. + +Why this file exists +-------------------- +Unauthenticated GETs to /wp-admin/* were reaching PHP, booting WordPress just to +produce a login redirect and exhausting lsphp pools under a distributed +low-and-slow attack. The gate redirects them at the edge instead. + +Two properties are easy to get wrong and invisible to `haproxy -c`: + + * ORDERING. `is_whitelisted` reads var(txn.real_ip). If the rule renders before + the set-var chain, the whitelist evaluates against an unset variable. + * THE ALLOWLIST. wp-login.php loads its OWN css/js from /wp-admin/. Dropping + those entries leaves every login page on the fleet unstyled, while still + returning 200 -- a silent regression. + +Running +------- + python3 scripts/test-wpadmin-gate.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 + +ALLOWLIST = ('/admin-ajax.php', '/admin-post.php', + '/load-styles.php', '/load-scripts.php') +EXCLUSIONS = ('!wp_admin_allowed', '!wp_admin_asset', '!has_wp_logged_in', + '!wp_gate_exempt', '!is_local', '!is_trusted_ip', '!is_whitelisted') + + +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, + ) + + +class WpAdminGate(unittest.TestCase): + + def setUp(self): + self.cfg = render_listener() + + def test_acls_declared(self): + self.assertRegex(self.cfg, r'acl\s+wp_admin_path\s+path_reg') + self.assertRegex(self.cfg, r'acl\s+wp_admin_asset\s+path_reg') + self.assertRegex(self.cfg, r'acl\s+wp_admin_allowed\s+path_end') + self.assertIn('/etc/haproxy/wpadmin_gate_exempt.list', self.cfg) + + def test_allowlist_entries_present(self): + """wp-login.php loads its own css/js from /wp-admin/ -- see module docstring.""" + for entry in ALLOWLIST: + with self.subTest(entry=entry): + self.assertIn(entry, self.cfg) + + def test_static_asset_dirs_allowed(self): + self.assertRegex(self.cfg, r'wp_admin_asset\s+path_reg.*\(css\|js\|images\)') + + def test_install_php_is_NOT_allowlisted(self): + """install.php is deliberately gated -- a takeover vector on abandoned installs.""" + m = re.search(r'acl\s+wp_admin_allowed\s+path_end([^\n]*)', self.cfg) + self.assertIsNotNone(m, 'wp_admin_allowed ACL not found') + self.assertNotIn('install.php', m.group(1)) + + def test_redirect_rule_has_all_exclusions(self): + m = re.search(r'http-request redirect[^\n]*wp_admin_path[^\n]*', self.cfg) + self.assertIsNotNone(m, 'wp-admin redirect rule not found') + rule = m.group(0) + for excl in EXCLUSIONS: + with self.subTest(exclusion=excl): + self.assertIn(excl, rule) + + def test_rule_renders_after_real_ip_resolution(self): + """is_whitelisted reads txn.real_ip; before the set-var chain it is unset.""" + setvar = self.cfg.index('set-var(txn.real_ip)') + rule = self.cfg.index('wp_admin_path') + self.assertLess(setvar, rule) + + def test_rule_renders_after_has_wp_logged_in_declared(self): + """HAProxy resolves ACLs as it parses; use-before-declare fails.""" + decl = self.cfg.index('acl has_wp_logged_in') + rule = self.cfg.index('wp_admin_path') + self.assertLess(decl, rule) + + def test_only_one_has_wp_logged_in_declaration(self): + self.assertEqual(self.cfg.count('acl has_wp_logged_in'), 1) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index 4ed87ae..d0892fd 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -183,6 +183,78 @@ frontend web http-request deny deny_status 403 if wp_batch_route !has_wp_logged_in !is_local !is_trusted_ip !is_whitelisted http-request deny deny_status 403 if wp_batch_route_enc !has_wp_logged_in !is_local !is_trusted_ip !is_whitelisted + # --- WordPress admin edge gate --- + # Measured on whp02, 2026-08-14: distributed unauthenticated GETs booting + # WordPress just to bounce back a login redirect -- + # 2801 GET /wp-admin/profile.php 2781 GET /wp-admin/edit.php 2761 GET /wp-admin/plugins.php + # -- spread across many source IPs at roughly 4 req/min per IP, each hit + # burning a PHP-FPM/lsphp worker. Two sites absorbed 1,243 resulting 503s + # in nine hours as their pools saturated. + # + # IDENTITY, NOT RATE. This is the one rule in this file that tracks + # nothing and has no stick-table counter or threshold. Every rate-based + # control above (the generic limits, wp_bruteforce, xmlrpc_bruteforce) is + # per-IP, and per-IP rate is exactly what this attack is engineered to + # stay under: ~4 req/min from any single IP is indistinguishable from a + # slow human, and the source set is large enough that no threshold can be + # lowered to catch it without also catching real visitors. There is also + # no free stick-table slot left to try anyway -- sc0/sc1/sc2 are already + # used above and HAProxy's default tune.stick-counters is 3, so a fourth + # tracked counter is not an option here. DO NOT "simplify" this into a + # rate/threshold rule later: the whole point is that a threshold cannot + # see this traffic. Instead we gate on identity -- a real logged-in + # WordPress user always carries a wordpress_logged_in_* cookie (the same + # ACL the wp2shell block above already declares), and an unauthenticated + # request to a wp-admin page has no legitimate reason to boot PHP at all. + # + # 302, not 403. WordPress itself redirects an unauthenticated /wp-admin/ + # request to wp-login.php, so replicating that at the edge means an admin + # whose session merely expired lands on the normal login screen instead + # of an error page -- we are not trading a bot problem for a support + # ticket. Bots get a cheap redirect they ignore. + # + # path_reg, not path_beg. A subdirectory install at /blog/wp-admin/ slips + # past a prefix match; path_reg with an optional leading "/" catches both + # root and subdirectory installs, same reasoning as the wp-login and + # xmlrpc path_end rules above. + # + # regsub rewrites the redirect target itself, so /blog/wp-admin/x.php + # redirects to /blog/wp-login.php rather than 404ing at the site root. + # + # redirect_to carries the path only (%[path,url_enc]), not the query + # string -- deliberate, see design spec. An admin bounced off + # post.php?post=123&action=edit lands back on a blank post.php rather + # than that exact post. Capturing the full URI needs capture.req.uri + # (extra config, and the captured value is length-capped) for a benefit + # that only matters on session expiry, so it was not worth it. + # + # THE ALLOWLIST IS MEASURED, NOT GUESSED -- taken from actual fleet + # traffic returning 200 on /wp-admin/*. admin-ajax.php and admin-post.php + # are the standard front-end AJAX/form-handler endpoints real themes and + # plugins call while logged out. Critically, wp-login.php loads its OWN + # css/js FROM /wp-admin/ (load-styles.php, load-scripts.php, and the + # static wp_admin_asset dirs below) -- miss those and every login page on + # the fleet renders unstyled with a broken password-strength meter, while + # wp-login.php itself still returns 200, making it a silent regression + # that "looks like" the gate is working. + # + # install.php is DELIBERATELY NOT allowlisted. It is legitimately + # reachable without a cookie during a fresh install, but it is also a + # standing scanner target and a real takeover vector on a site that was + # half-installed and then abandoned. Anyone genuinely installing uses the + # per-site exempt-list opt-out below instead. + # + # Honors the same whitelist as every other rule in this frontend + # (RFC1918 / trusted_ips.list / trusted_ips.map), and a per-site opt-out + # via /etc/haproxy/wpadmin_gate_exempt.list (operator-managed, seeded + # empty by start-up.sh) for sites where a plugin legitimately serves + # unauthenticated visitors from a /wp-admin/ URL outside this allowlist. + acl wp_admin_path path_reg (^|/)wp-admin/ + acl wp_admin_allowed path_end /admin-ajax.php /admin-post.php /load-styles.php /load-scripts.php + acl wp_admin_asset path_reg (^|/)wp-admin/(css|js|images)/ + acl wp_gate_exempt hdr(host),lower -f /etc/haproxy/wpadmin_gate_exempt.list + http-request redirect code 302 location %[path,regsub((^|/)wp-admin/.*,\1wp-login.php)]?redirect_to=%[path,url_enc] if wp_admin_path !wp_admin_allowed !wp_admin_asset !has_wp_logged_in !wp_gate_exempt !is_local !is_trusted_ip !is_whitelisted + # IP blocking using map file (manual blocks only) # Map file format: /etc/haproxy/blocked_ips.map contains " 1" per line # Runtime updates: echo "add map #0 IP_ADDRESS 1" | socat stdio /var/run/haproxy.sock