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/<file>.

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 08:02:00 -07:00
co-authored by Claude Opus 5
parent 704be38882
commit 6b0b5893b6
2 changed files with 88 additions and 2 deletions
+43
View File
@@ -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-admin)' + re.escape(entry) + r'(?!\S)',
'found a bare, unanchored allowlist entry: ' + entry)
def test_wp_login_url_setvar_renders_in_correct_order(self):
"""The inline regsub-in-`location` form is rejected by real HAProxy
3.0.11 (invalid arg 2 in converter 'regsub'), so the regsub is
computed in its own set-var line instead. That set-var must render
after the set-var(txn.real_ip) chain (it must not disturb that
load-bearing chain) and before the redirect rule that consumes it.
"""
self.assertIn('set-var(txn.wp_login_url)', self.cfg)
last_real_ip_setvar = self.cfg.rindex('set-var(txn.real_ip)')
wp_login_setvar = self.cfg.index('set-var(txn.wp_login_url)')
redirect_rule = self.cfg.index('http-request redirect code 302 location %[var(txn.wp_login_url)]')
self.assertLess(last_real_ip_setvar, wp_login_setvar,
'wp_login_url set-var must render after the real_ip set-var chain')
self.assertLess(wp_login_setvar, redirect_rule,
'wp_login_url set-var must render before the redirect rule that uses it')
def test_redirect_rule_uses_setvar_not_inline_regsub(self):
"""Guards against reintroducing the rejected inline form."""
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)
self.assertIn('%[var(txn.wp_login_url)]', rule)
self.assertNotIn('regsub', rule)
if __name__ == '__main__':
unittest.main(verbosity=2)
+45 -2
View File
@@ -221,6 +221,38 @@ frontend web
# 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.
#
# DO NOT write regsub's regex argument with a capturing group / literal
# parentheses, e.g. regsub((^|/)wp-admin/.*,\1wp-login.php) -- neither
# inlined into the redirect's `location` nor in a standalone set-var.
# HAProxy 3.0.11's converter-argument parser counts parens to find the
# end of the regsub(...) call itself, so the *inner* "(^|/)" grouping
# parens are misread as closing the outer call early -- it does not
# matter whether the argument is quoted ("...": still fails) or the
# parens are backslash-escaped (\(...\): still fails). Every such form
# was verified against real HAProxy 3.0.11-1+deb13u3 and all produce the
# same ALERT: "invalid arg 2 in converter 'regsub' : missing arguments
# (got 1/2)". This is a converter-argument-parsing limitation, not a
# log-format/`%[...]` issue -- the identical failure reproduces in a
# plain set-var (outside any log-format string), which rules out the
# `location` value's log-format context as the cause.
#
# The fix sidesteps groups/backreferences entirely: HTTP paths always
# start with "/", so the leading "(^|/)" alternation is redundant --
# matching the literal substring "/wp-admin/" (both slashes, no group)
# is sufficient to anchor to a real path segment (a false match like
# "/somewp-admin/" doesn't contain "/wp-admin/" as a substring, since
# there's no "/" directly before "wp-admin"). No backreference is
# needed either: regsub only replaces the matched substring, so
# replacing "/wp-admin/.*" with a literal "/wp-login.php" leaves
# whatever precedes it (the subdirectory-install prefix, if any)
# untouched. Computed in its own set-var so it is a plain sample
# expression, not something baked into the redirect's log-format
# string. Behaviorally verified live against real HAProxy 3.0.11:
# /wp-admin/edit.php -> /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/<file>, 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 "<ip_or_cidr> 1" per line