fix(haproxy): close open redirect in wp-admin edge gate

The redirect target is built by regsub-rewriting `path`, which only
replaces the matched "/wp-admin/.*" substring -- anything before it
survives untouched. Three request forms turn that survival into an
off-site Location header: a protocol-relative "//evil/wp-admin/x.php",
a browser-normalized "/\evil/wp-admin/x.php", and an RFC 7230
absolute-form request target. Without this gate those paths simply
404 against WordPress; the gate itself is what would have exposed a
fleet-wide phishing primitive.

Adds a positive wp_admin_safe_path ACL (path_reg ^/[^/\\]) requiring a
well-formed absolute path, required alongside the existing conditions
on the redirect rule. A path that fails it is simply not redirected
and falls through to the backend -- pre-gate behavior, so no
regression. set-var is left unguarded since it only computes a
variable; the redirect is what emits the header, so guarding it is
sufficient.

Verified against real HAProxy 3.0.11: the naive two-backslash form
fails to compile (config-line word parsing collapses "\\" to one
backslash before PCRE sees it, leaving an unterminated class); four
backslashes are required in the template so PCRE receives the
intended single-backslash class member. Confirmed live, via a
differential test against the pre-fix rule, that both the // and /\
vectors previously produced off-site Location headers and now do not,
while normal root and subdirectory-install redirects are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 08:19:02 -07:00
co-authored by Claude Opus 5
parent 6b0b5893b6
commit 18750861b4
2 changed files with 66 additions and 1 deletions
+27
View File
@@ -149,6 +149,33 @@ class WpAdminGate(unittest.TestCase):
self.assertIn('%[var(txn.wp_login_url)]', rule)
self.assertNotIn('regsub', rule)
def test_redirect_rule_requires_safe_path(self):
"""OPEN REDIRECT guard. The redirect target is built by rewriting
`path` with regsub, which only replaces the matched substring --
everything before "/wp-admin/" survives untouched in the output.
Three concrete requests turn that into an off-site `Location:`
header: "//evil.example.com/wp-admin/x.php" (protocol-relative,
browsers resolve "//host/path" to "https://host/path"),
"/\\evil.example.com/wp-admin/x.php" (browsers normalise a leading
"/\\" the same as "//"), and an RFC 7230 absolute-form request
target ("https://evil.example.com/wp-admin/x.php") which can make
HAProxy's `path` fetch return a full URI. wp_admin_safe_path
(requiring a well-formed absolute path) must be a POSITIVE
condition on the redirect rule -- scoped to the captured rule line
only, since the surrounding comment block also mentions this ACL
name and a bare substring match would pass even if the condition
were dropped from the rule itself.
"""
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('wp_admin_safe_path', rule)
self.assertNotIn('!wp_admin_safe_path', rule,
'wp_admin_safe_path must be a positive condition, not negated')
def test_wp_admin_safe_path_acl_declared(self):
self.assertRegex(self.cfg, r'acl\s+wp_admin_safe_path\s+path_reg')
if __name__ == '__main__':
unittest.main(verbosity=2)
+39 -1
View File
@@ -291,12 +291,50 @@ frontend web
# 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.
# wp_admin_safe_path guards against an OPEN REDIRECT this gate would
# otherwise introduce. The redirect target below is built by rewriting
# `path` with regsub -- regsub only replaces the matched substring, so
# everything BEFORE the matched "/wp-admin/" survives untouched in the
# output. `path` is not guaranteed to be a clean site-relative string;
# three concrete requests turn that survival into an off-site
# `Location:` header (verified against real HAProxy semantics):
# //evil.example.com/wp-admin/x.php -> //evil.example.com/wp-login.php
# (protocol-relative -- browsers resolve "//host/path" to
# "https://host/path", so this redirects off-site with no scheme
# needed)
# /\evil.example.com/wp-admin/x.php -> /\evil.example.com/wp-login.php
# (browsers normalise a leading "/\" the same as "//")
# https://evil.example.com/wp-admin/x.php -> https://evil.example.com/wp-login.php
# (RFC 7230 absolute-form request targets can make HAProxy's `path`
# fetch return a full URI, not just the path component)
# None of these vectors reach WordPress today -- this gate is what would
# newly expose them as a phishing primitive on every customer domain on
# the fleet. wp_admin_safe_path requires a well-formed absolute path
# (leading "/" not followed by another "/" or a backslash) and is a
# POSITIVE condition on the redirect rule, not a negation: a path that
# fails it simply is not redirected and falls through to the backend --
# today's (pre-gate) behavior, so failing the check is never a
# regression, only a missed redirect on a pathological input. DO NOT
# remove this ACL as redundant with wp_admin_path -- wp_admin_path's
# `path_reg (^|/)wp-admin/` happily matches all three vectors above.
acl wp_admin_path path_reg (^|/)wp-admin/
# Four literal backslashes here is NOT a typo. HAProxy's config-line word
# parser treats backslash as its OWN escape character before the value
# ever reaches the regex engine: "\\" (two backslashes) in the config
# collapses to one literal backslash by the time PCRE compiles it, which
# leaves an unterminated character class ("[^/\]") and fails with
# "missing terminating ] for character class" -- verified against real
# HAProxy 3.0.11. Four backslashes ("\\\\") collapse to two ("\\"),
# which PCRE then reads as a single escaped-backslash class member --
# the intended "reject a literal backslash" semantics. Confirmed live:
# this form accepts /wp-admin/... and /blog/wp-admin/... while rejecting
# both //host/wp-admin/... and /\host/wp-admin/....
acl wp_admin_safe_path path_reg ^/[^/\\\\]
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 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
http-request redirect code 302 location %[var(txn.wp_login_url)]?redirect_to=%[path,url_enc] if wp_admin_path wp_admin_safe_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