From 6b0b5893b6c0a812c5634a1e2033292f033b79a6 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 14 Aug 2026 08:02:00 -0700 Subject: [PATCH] fix(haproxy): repair wp-admin edge gate redirect + anchor allowlist Two defects in the wp-admin edge gate (2171bed, 704be38): 1. HAProxy 3.0.11 rejects the inline regsub redirect (regsub((^|/)wp-admin/.*,\1wp-login.php)) with "invalid arg 2 in converter 'regsub': missing arguments". Verified this is a converter-argument-parenthesis-counting limitation -- the inner "(^|/)" grouping parens are misread as closing the outer regsub() call, and neither quoting nor backslash-escaping the parens helps. Since HTTP paths always start with "/", the group is unnecessary: compute the login URL in its own set-var, matching the literal substring "/wp-admin/" (no group, no backreference) and replacing it with the literal "/wp-login.php" -- regsub only replaces the matched substring, so a subdirectory-install prefix survives untouched. 2. wp_admin_allowed used a bare path_end suffix match (/admin-ajax.php etc), so /wp-admin/evil/admin-ajax.php matched both wp_admin_path and the allowlist and sailed through the gate ungated. Anchored each entry to /wp-admin/. Verified against real HAProxy 3.0.11-1+deb13u3: haproxy -c exit 0, and live curl against the real generated config's literal lines confirms root-install and subdirectory-install redirects, the anchored-allowlist fix, cookie exemption, and non-wp-admin passthrough all behave correctly. Extends scripts/test-wpadmin-gate.py with regression tests for the anchored allowlist and the set-var ordering/no-inline-regsub guard. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/test-wpadmin-gate.py | 43 +++++++++++++++++++++++++++++++++ templates/hap_listener.tpl | 47 ++++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/scripts/test-wpadmin-gate.py b/scripts/test-wpadmin-gate.py index 8cef3e4..a00df20 100644 --- a/scripts/test-wpadmin-gate.py +++ b/scripts/test-wpadmin-gate.py @@ -106,6 +106,49 @@ class WpAdminGate(unittest.TestCase): def test_only_one_has_wp_logged_in_declaration(self): self.assertEqual(self.cfg.count('acl has_wp_logged_in'), 1) + def test_allowlist_entries_are_anchored_to_wp_admin(self): + """Bare `path_end /admin-ajax.php` also matches + /wp-admin/evil/admin-ajax.php, which ALSO matches wp_admin_path + (path_reg only requires /wp-admin/ to appear somewhere) -- an + attacker-inserted path segment would then sail through the + allowlist ungated. Entries must be anchored to sit directly under + wp-admin/. Scoped to the captured ACL line only, since the + surrounding comment block also mentions these bare filenames. + """ + m = re.search(r'acl\s+wp_admin_allowed\s+path_end([^\n]*)', self.cfg) + self.assertIsNotNone(m, 'wp_admin_allowed ACL not found') + line = m.group(1) + for entry in ALLOWLIST: + with self.subTest(entry=entry): + self.assertIn('/wp-admin' + entry, line) + self.assertNotRegex( + line, r'(? /wp-login.php and + # /blog/wp-admin/plugins.php -> /blog/wp-login.php, both with + # redirect_to preserved. See + # .superpowers/sdd/2026-08-14-wpadmin-edge-gate/task-3b-report.md. + # # 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 @@ -238,6 +270,16 @@ frontend web # wp-login.php itself still returns 200, making it a silent regression # that "looks like" the gate is working. # + # Each allowlist entry is anchored to /wp-admin/, not a bare + # filename suffix. A bare `path_end /admin-ajax.php` also matches + # /wp-admin/evil/admin-ajax.php -- which ALSO matches wp_admin_path + # (path_reg only requires /wp-admin/ to appear somewhere), so an + # attacker-inserted path segment would sail through this allowlist + # ungated and boot full WordPress, exactly the resource exhaustion this + # gate exists to stop. Anchoring still covers subdirectory installs via + # suffix matching (/blog/wp-admin/admin-ajax.php ends with + # /wp-admin/admin-ajax.php) while rejecting an inserted directory. + # # 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 @@ -250,10 +292,11 @@ frontend web # 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_allowed path_end /wp-admin/admin-ajax.php /wp-admin/admin-post.php /wp-admin/load-styles.php /wp-admin/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 + http-request set-var(txn.wp_login_url) path,regsub(/wp-admin/.*,/wp-login.php) if wp_admin_path + http-request redirect code 302 location %[var(txn.wp_login_url)]?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