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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user