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)