From 18750861b49249e11b5855141eca0de2eb32e162 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 14 Aug 2026 08:19:02 -0700 Subject: [PATCH] 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) --- scripts/test-wpadmin-gate.py | 27 ++++++++++++++++++++++++ templates/hap_listener.tpl | 40 +++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/scripts/test-wpadmin-gate.py b/scripts/test-wpadmin-gate.py index a00df20..309a286 100644 --- a/scripts/test-wpadmin-gate.py +++ b/scripts/test-wpadmin-gate.py @@ -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) diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index 32c1ff2..dc89044 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -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 " 1" per line