From 8a8d9c5fe35c80c26e1aa3099e25394b3e4bd79b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 14 Aug 2026 09:09:35 -0700 Subject: [PATCH] fix(haproxy): normalise the URI before matching, closing five gate bypasses The wp-admin edge gate matched the RAW request path while the backend normalised and decoded it before resolving a file. Every gap between those two behaviours was a bypass, and five had already been patched individually: //wp-admin/plugins.php fell through ungated /wp-admin/css/../plugins.php took the static-asset bypass /wp-admin/js/%2e%2e/plugins.php same, percent-encoded /wp%2Dadmin/plugins.php matched no wp-admin ACL at all /wp-admin%2Fplugins.php encoded separator, served by OLS Stop patching vectors and normalise once, first, so every path-based rule in the frontend sees the same string the backend will resolve: percent-to-uppercase percent-decode-unreserved path-merge-slashes path-strip-dot path-strip-dotdot full Order was determined empirically against real haproxy 3.0.11, not from the docs: the decoders MUST precede the path walkers, or %2e%2e is decoded to .. only after path-strip-dotdot has already run and the traversal survives. Plain path-strip-dotdot also leaves /../../ untouched -- "full" is required. query-sort-by-name is deliberately not enabled; it reorders query parameters and would break anything signing or caching on the exact query string. normalize-uri is experimental in 3.0, so global gains expose-experimental-directives -- without it haproxy does not start at all. The two must be added and removed together. %2F cannot be closed by normalisation ("/" is reserved, so decoding it is correctly refused), so it gets its own deny, scoped to paths mentioning wp-admin so non-WordPress apps that pass encoded slashes in path parameters keep working. Deny rather than redirect: regsub finds no "/wp-admin/" in "/wp-admin%2F...", so a redirect would point at the request's own URL. Gate changes: * wp_admin_safe_path KEPT -- merge-slashes kills its "//" vector but not "/\", which no normalizer touches. Its failure mode (unsafe path is not redirected, therefore falls through UNGATED -- the original C1) is now closed by an explicit deny instead of being left implicit. * wp_admin_asset now excludes .php, so the asset bypass cannot cover a PHP entrypoint even if an encoding trick ever survives normalisation. * wp_admin_path is case-insensitive, paired with a matching regsub flag -- adding either alone is an infinite redirect loop. Verified behaviourally against real haproxy 3.0.11 with raw sockets (curl normalises client-side and hides these), run twice: once against the rendered templates and once against the haproxy.cfg generated by a real, healthy container. 12/12 gated, 19/19 passed through, 7/7 with no off-site Location, plus ~30 adversarial vectors. haproxy -c exits 0 and the container reaches healthy. Blast radius measured on a 40-URL production-shaped corpus: 4 rewritten, all RFC-equivalent (%7E->~, /./ , //); query strings and all non-unreserved escapes byte-identical. Full evidence: .superpowers/sdd/2026-08-14-wpadmin-edge-gate/task-4-normalize-report.md Co-Authored-By: Claude Opus 5 (1M context) --- scripts/test-wpadmin-gate.py | 177 ++++++++++++++++++++++++++++++ templates/hap_header.tpl | 15 +++ templates/hap_listener.tpl | 206 +++++++++++++++++++++++++++++++---- 3 files changed, 378 insertions(+), 20 deletions(-) diff --git a/scripts/test-wpadmin-gate.py b/scripts/test-wpadmin-gate.py index 309a286..53a1aeb 100644 --- a/scripts/test-wpadmin-gate.py +++ b/scripts/test-wpadmin-gate.py @@ -48,6 +48,16 @@ ALLOWLIST = ('/admin-ajax.php', '/admin-post.php', EXCLUSIONS = ('!wp_admin_allowed', '!wp_admin_asset', '!has_wp_logged_in', '!wp_gate_exempt', '!is_local', '!is_trusted_ip', '!is_whitelisted') +# The normalizer set, in the order it MUST render. Decoding has to precede the +# path walkers or "%2e%2e" is decoded to ".." only after path-strip-dotdot has +# already run, leaving the ".." unresolved -- measured against real HAProxy +# 3.0.11, both orders side by side. +NORMALIZERS = ('percent-to-uppercase', + 'percent-decode-unreserved', + 'path-merge-slashes', + 'path-strip-dot', + 'path-strip-dotdot full') + def render_listener(): return haproxy_manager.template_env.get_template('hap_listener.tpl').render( @@ -57,6 +67,24 @@ def render_listener(): ) +def render_header(): + return haproxy_manager.template_env.get_template('hap_header.tpl').render( + cluster_secret=None, + ) + + +def rule_lines(cfg, needle): + """Non-comment config lines containing `needle`. + + Every assertion about a RULE must be scoped this way. This file's + surrounding comment blocks quote the ACL names and even whole rules, so a + bare `assertIn` over the rendered config passes just as happily when the + rule has been deleted and only its explanation remains. + """ + return [ln.strip() for ln in cfg.split('\n') + if needle in ln and not ln.strip().startswith('#')] + + class WpAdminGate(unittest.TestCase): def setUp(self): @@ -176,6 +204,155 @@ class WpAdminGate(unittest.TestCase): def test_wp_admin_safe_path_acl_declared(self): self.assertRegex(self.cfg, r'acl\s+wp_admin_safe_path\s+path_reg') + def test_unsafe_wp_admin_path_is_denied_not_passed_through(self): + """wp_admin_safe_path being a POSITIVE condition on the redirect means + a path that fails it is simply not redirected -- which used to mean it + fell through to the backend UNGATED, i.e. exactly the PHP-booting + request the gate exists to stop. Normalisation removes the "//" + spelling of that, but not "/\\", so the fall-through must be closed + with an explicit deny rather than left implicit. + """ + denies = rule_lines(self.cfg, '!wp_admin_safe_path') + self.assertTrue(denies, + 'no rule denies a wp-admin path that fails wp_admin_safe_path') + self.assertTrue(any(d.startswith('http-request deny') for d in denies), + 'the !wp_admin_safe_path rule must be a deny: %r' % denies) + + +class UriNormalisation(unittest.TestCase): + """The gate matches the RAW path; the backend normalises and decodes it. + Every gap between those is a bypass -- five were found this way. These + tests pin the normalisation that closes the gap as a class. + + NOTE: these are config-TEXT assertions. They are necessary but NOT + sufficient: the previous revision of this file passed while five live + bypasses shipped. The real evidence is the behavioural matrix run against + real haproxy 3.0.11 with raw sockets -- see + .superpowers/sdd/2026-08-14-wpadmin-edge-gate/task-4-normalize-report.md. + """ + + def setUp(self): + self.cfg = render_listener() + self.header = render_header() + + def test_experimental_directives_exposed_in_global(self): + """normalize-uri is experimental in 3.0; without this HAProxy does not + start at all (ALERT, exit 1), which crash-loops the container. + """ + self.assertTrue( + [ln for ln in self.header.split('\n') + if ln.strip() == 'expose-experimental-directives'], + 'expose-experimental-directives missing from the global section') + + def test_all_normalizers_render(self): + for norm in NORMALIZERS: + with self.subTest(normalizer=norm): + self.assertTrue( + rule_lines(self.cfg, 'normalize-uri ' + norm), + 'missing normalizer: ' + norm) + + def test_normalizer_order_decode_before_path_walkers(self): + """Reverse this order and /wp-admin/js/%2e%2e/plugins.php reaches the + ACLs as /wp-admin/js/../plugins.php -- decoded but unresolved. + """ + rendered = [ln for ln in self.cfg.split('\n') + if ln.strip().startswith('http-request normalize-uri')] + names = [ln.strip().split('normalize-uri ', 1)[1] for ln in rendered] + self.assertEqual(names, list(NORMALIZERS)) + + def test_normalisation_precedes_every_path_based_rule(self): + """A normalizer placed after a path rule normalises nothing for it.""" + last_norm = max(self.cfg.index('http-request normalize-uri ' + n) + for n in NORMALIZERS) + for marker in ('acl is_health_check', 'acl wp_login_path', + 'acl xmlrpc_path', 'acl wp_batch_path', + 'acl wp_admin_path', 'http-request set-path'): + with self.subTest(rule=marker): + self.assertLess(last_norm, self.cfg.index(marker), + marker + ' renders before URI normalisation') + + def test_query_sort_by_name_is_not_enabled(self): + """It reorders query parameters, breaking anything that signs or caches + on the exact query string, and buys this gate nothing (every rule here + matches on `path`, which excludes the query). + """ + self.assertFalse(rule_lines(self.cfg, 'normalize-uri query-sort-by-name')) + + def test_dotdot_normalizer_uses_full(self): + """Without "full", ".." segments that climb above the root are left in + place and /../../wp-admin/plugins.php survives -- measured. + """ + lines = rule_lines(self.cfg, 'normalize-uri path-strip-dotdot') + self.assertTrue(lines) + for ln in lines: + self.assertTrue(ln.endswith('path-strip-dotdot full'), ln) + + def test_encoded_separator_on_wp_admin_is_denied(self): + """percent-decode-unreserved deliberately leaves %2F encoded ("/" is + reserved), but OpenLiteSpeed decodes it and serves the file -- + /wp-admin%2Fplugins.php was measured booting PHP on the OLS tier while + matching no wp-admin ACL. Normalisation cannot close this; it needs its + own rule. + """ + denies = [ln for ln in rule_lines(self.cfg, 'path_has_encoded_sep') + if ln.startswith('http-request deny')] + self.assertTrue(denies, 'no deny rule for encoded separators') + acl = rule_lines(self.cfg, 'acl path_has_encoded_sep') + self.assertTrue(acl) + self.assertIn('%2f', acl[0].lower()) + + def test_encoded_separator_deny_is_scoped_to_wp_admin(self): + """A blanket "deny any %2F in any path" would break non-WordPress + customer apps that legitimately pass an encoded slash in a path + parameter. The deny must be conditioned on the path mentioning + wp-admin. + """ + denies = [ln for ln in rule_lines(self.cfg, 'path_has_encoded_sep') + if ln.startswith('http-request deny')] + for d in denies: + self.assertIn('wp_admin_word', d) + + def test_encoded_separator_deny_honors_the_same_whitelist(self): + denies = [ln for ln in rule_lines(self.cfg, 'path_has_encoded_sep') + if ln.startswith('http-request deny')] + for d in denies: + for excl in ('!has_wp_logged_in', '!wp_gate_exempt', '!is_local', + '!is_trusted_ip', '!is_whitelisted'): + with self.subTest(rule=d, exclusion=excl): + self.assertIn(excl, d) + + def test_encoded_separator_acl_matches_a_substring_not_a_prefix(self): + """/blog%2Fwp-admin/plugins.php hides the separator BEFORE "wp-admin", + where an anchored pattern never matches, and OLS still resolves it. + """ + acl = rule_lines(self.cfg, 'acl path_has_encoded_sep')[0] + self.assertIn('-m sub', acl) + + def test_wp_admin_asset_bypass_cannot_cover_a_php_entrypoint(self): + """The asset bypass anchored its prefix but not its suffix, so + /wp-admin/css/../plugins.php took it and the backend then resolved + ".." and booted plugins.php. path-strip-dotdot is the real fix; this + keeps the bypass structurally incapable of covering PHP. + """ + acl = rule_lines(self.cfg, 'acl wp_admin_asset')[0] + self.assertIn('.php', acl, + 'wp_admin_asset must exclude .php explicitly') + + def test_case_insensitive_acl_and_regsub_are_kept_in_sync(self): + """A case-insensitive wp_admin_path with a case-sensitive regsub is an + INFINITE REDIRECT LOOP: regsub finds no "/wp-admin/" in + "/WP-ADMIN/plugins.php", returns `path` unchanged, and the Location + then points at the request's own URL. + """ + acl = rule_lines(self.cfg, 'acl wp_admin_path')[0] + setvar = rule_lines(self.cfg, 'set-var(txn.wp_login_url)')[0] + acl_ci = bool(re.search(r'path_reg\s+-i\s', acl)) + regsub_ci = bool(re.search(r'regsub\([^)]*,\s*i\)', setvar)) + self.assertEqual( + acl_ci, regsub_ci, + 'wp_admin_path case-sensitivity (%s) and regsub flags (%s) disagree' + % (acl, setvar)) + if __name__ == '__main__': unittest.main(verbosity=2) diff --git a/templates/hap_header.tpl b/templates/hap_header.tpl index 322ae0e..d041950 100644 --- a/templates/hap_header.tpl +++ b/templates/hap_header.tpl @@ -27,6 +27,21 @@ global # SSL and Performance tune.ssl.default-dh-param 2048 + # Required by the `http-request normalize-uri` chain at the top of the + # `web` frontend (hap_listener.tpl). normalize-uri is still flagged + # EXPERIMENTAL in HAProxy 3.0, and HAProxy REFUSES TO START without this + # opt-in -- not a warning, a fatal: + # [ALERT] config : parsing [...] : 'normalize-uri' action is + # experimental, must be allowed via a global + # 'expose-experimental-directives' + # (verified against real haproxy 3.0.11-9e587df: `haproxy -c` exits 1). + # So this line and the normalize-uri rules must be added/removed together; + # dropping this one alone crash-loops every container on the fleet. + # + # This exposes ONLY the experimental directives that are actually used -- + # it does not change the behaviour of anything else in this file. + expose-experimental-directives + # HTTP/3 over QUIC. The Debian haproxy package is built against system # OpenSSL via the compatibility shim (USE_QUIC_OPENSSL_COMPAT), which is # not a native QUIC TLS stack. HAProxy therefore rejects `quic*@` binds diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index dc89044..08daa3b 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -22,6 +22,83 @@ frontend web # Capture Host header so it appears in httplog output (in %hr field) http-request capture req.hdr(Host) len 64 + # --- URI normalisation (MUST be the first path-touching block here) --- + # Every path-based control in this frontend (the ACME health-check bypass, + # wp-login, xmlrpc, the wp-json/batch virtual patch, the wp-admin gate, the + # blocked-IP and suspension set-path rules, and everything Coraza inspects) + # matched the RAW request-target while the backend NORMALISED and DECODED + # it before resolving a file. Every gap between those two behaviours is a + # bypass, and each one had to be patched individually. Five were found in + # the wp-admin gate alone, all the same class: + # + # //wp-admin/plugins.php raw path starts "//" -> the safe-path + # guard failed -> request fell through + # ungated, backend served it + # /wp-admin/css/../plugins.php matched the css/js/images asset + # bypass; backend resolved ".." and + # booted plugins.php + # /wp-admin/js/%2e%2e/plugins.php same, with the ".." percent-encoded + # /wp%2Dadmin/plugins.php "wp-admin" spelled with %2D never + # matched any wp-admin ACL at all + # /wp-admin%2Fplugins.php encoded separator; see the dedicated + # rule in the wp-admin gate below + # + # Rather than keep bolting a counter-pattern onto each rule, normalise the + # URI once, here, so every rule below matches the SAME string the backend + # will resolve. HAProxy rewrites the request-target in place, so the + # backend receives the normalised form too. + # + # ORDER IS LOAD-BEARING and was determined empirically against real + # haproxy 3.0.11, not from the docs. The decoders must run BEFORE the path + # walkers: with the reverse order, /wp-admin/js/%2e%2e/plugins.php ends up + # as /wp-admin/js/../plugins.php -- decoded, but the ".." left unresolved, + # because path-strip-dotdot had already run by the time the "%2e%2e" + # became "..". Verified both directions side by side. + # + # percent-to-uppercase %2f -> %2F. Canonicalises the spelling of + # whatever stays encoded, so downstream rules + # need one case of each escape, not two. + # percent-decode-unreserved Decodes ONLY RFC 3986 unreserved chars + # (A-Za-z0-9-._~). This is what turns %2e%2e + # into .. and wp%2Dadmin into wp-admin. + # Reserved escapes are deliberately left + # alone -- %2F in particular, which is why + # the wp-admin gate needs its own encoded- + # slash rule (see below). + # path-merge-slashes //x -> /x. Also removes the entire class of + # "leading // defeats an anchored regex". + # path-strip-dot /a/./b -> /a/b. + # path-strip-dotdot full /a/b/../c -> /a/c. "full" additionally + # resolves ".." segments that would climb + # above the root (/../../wp-admin/x.php -> + # /wp-admin/x.php); without "full" HAProxy + # leaves those in place and the vector + # survives -- measured, both forms tested. + # + # DELIBERATELY NOT ENABLED: query-sort-by-name. It reorders query-string + # parameters, which silently breaks anything that signs or caches on the + # exact query string (signed asset URLs, HMAC'd callbacks, CDN cache + # keys). It buys this gate nothing -- every rule here matches on `path`, + # which excludes the query string. + # + # BLAST RADIUS: this block applies to EVERY request for EVERY site on + # EVERY tier, so the decoding was kept minimal on purpose. + # percent-decode-unreserved touches only unreserved characters, so + # %20 (space), %2B, %C3%A9 and friends pass through byte-identical -- + # verified. The only rewrite a normal site can notice is %7E -> ~ , which + # RFC 3986 defines as the same URI, plus the merge/dot resolution the + # backend would have performed anyway. + # + # normalize-uri is EXPERIMENTAL in 3.0 and requires + # `expose-experimental-directives` in the global section + # (hap_header.tpl). Without it HAProxy does not start. Remove one and you + # must remove the other. + http-request normalize-uri percent-to-uppercase + http-request normalize-uri percent-decode-unreserved + http-request normalize-uri path-merge-slashes + http-request normalize-uri path-strip-dot + http-request normalize-uri path-strip-dotdot full + # --- Trusted-proxy gate (MUST precede real-IP resolution below) --- # CF-Connecting-IP / X-Real-IP / X-Forwarded-For are client-supplied. Any # peer that is not a known reverse proxy gets them stripped, so the @@ -291,33 +368,68 @@ 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. + # EVERY ACL BELOW MATCHES THE NORMALISED PATH. The normalize-uri chain at + # the top of this frontend has already merged duplicate slashes, resolved + # "." / ".." segments (including percent-encoded ones) and decoded + # unreserved escapes by the time these run, so these patterns only have to + # describe the ONE canonical spelling the backend will resolve -- they do + # not have to anticipate every encoding of it. That is the whole point of + # the normalisation block; do not "harden" these regexes by re-adding + # encoding variants, fix the normalisation instead. + # # 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): + # `Location:` header: # //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) + # needed). NOW NEUTRALISED UPSTREAM: path-merge-slashes rewrites this + # to /evil.example.com/wp-admin/x.php before any ACL sees it, so the + # Location becomes the same-origin /evil.example.com/wp-login.php. + # Verified live. # /\evil.example.com/wp-admin/x.php -> /\evil.example.com/wp-login.php - # (browsers normalise a leading "/\" the same as "//") + # (browsers normalise a leading "/\" the same as "//"). STILL LIVE + # after normalisation -- a backslash is not a slash, so no normalizer + # touches it. This ACL is the only thing that stops it. # 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/ + # fetch return a full URI, not just the path component). HAProxy's own + # H1 parser answers 400 on this frontend; this ACL is the backstop. + # So wp_admin_safe_path is NOT redundant with the normalisation and must + # not be deleted as such -- one of its three vectors survives normalisation + # untouched. + # + # It is used TWO ways, and the pair matters: + # - as a POSITIVE condition on the redirect, so a pathological path can + # never produce a `Location:` header at all; and + # - as an explicit deny, so such a path is not merely un-redirected. + # The deny is what closes the failure mode the positive-condition form + # introduced on its own: "not redirected" used to mean "falls through to + # the backend UNGATED", i.e. the exact PHP-booting request this gate + # exists to stop, reachable by prefixing "//" (that specific spelling is + # now normalised away, but "/\" is not). Post-normalisation the only + # paths that reach the deny are "/\..." ones, which cannot resolve to a + # real file on any tier, so nothing legitimate is denied. + # -i (case-insensitive) and the matching ",i" flag on the regsub below are + # a PAIR -- adding one without the other produces an infinite redirect + # loop, because a case-sensitive regsub finds no "/wp-admin/" in + # "/WP-ADMIN/plugins.php", returns `path` UNCHANGED, and the Location then + # points at the request's own URL. Verified live that the pair is correct: + # /WP-ADMIN/plugins.php -> /wp-login.php and /blog/WP-Admin/plugins.php -> + # /blog/wp-login.php. + # + # On this fleet's Linux backends /WP-ADMIN/plugins.php 404s without booting + # PHP, so this is hardening rather than a live-bypass fix; it matters if a + # docroot ever sits on a case-insensitive mount, where that same request + # WOULD boot PHP. The cost is that a site with a real directory literally + # named e.g. /docs/WP-Admin/ now gets gated -- the same false positive the + # lowercase pattern already has, which is what the per-site exempt list + # exists to resolve. + acl wp_admin_path path_reg -i (^|/)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 @@ -326,14 +438,68 @@ frontend web # "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/.... + # the intended "reject a literal backslash" semantics. 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)/ + # The (?!.*\.php) lookahead is DEFENCE IN DEPTH, not the primary fix. This + # ACL grants an un-gated bypass to everything under wp-admin/css|js|images, + # and it used to anchor its prefix but not its suffix, so + # /wp-admin/css/../plugins.php took the bypass and the backend then + # resolved ".." and booted plugins.php. path-strip-dotdot now rewrites that + # to /wp-admin/plugins.php before this ACL runs, which is the real fix; the + # lookahead additionally makes the bypass structurally incapable of + # covering a PHP entrypoint even if a future encoding trick survives + # normalisation. It excludes ".php" ONLY -- no static asset contains that + # substring, so it cannot cause the silent "login page renders unstyled" + # regression that an extension allowlist would risk. Requires PCRE2, which + # both the Debian (deployed) and Alpine haproxy builds have (+PCRE2). + acl wp_admin_asset path_reg (^|/)wp-admin/(css|js|images)/(?!.*\.php).*$ 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 + # ENCODED SEPARATOR. percent-decode-unreserved deliberately does NOT decode + # %2F -- "/" is a reserved character, and decoding it in the normalizer + # would change the path's structure (it would invent new segments), which + # is precisely why HAProxy refuses to. But OpenLiteSpeed DOES decode it and + # then serves the file: /wp-admin%2Fplugins.php was measured returning 302 + # from a real WordPress site on the OLS tier, i.e. full PHP boot, while + # matching none of the ACLs above. Apache returns 404 for the same request + # (AllowEncodedSlashes Off), so this is an OLS-tier defect -- and OLS is the + # tier currently saturating. + # + # DENY, not "treat it as a wp-admin path and redirect". Two reasons: + # 1. The redirect target is computed by regsub(/wp-admin/.*) which finds + # no "/wp-admin/" in "/wp-admin%2Fplugins.php", so `path` would come + # back UNCHANGED and the Location would point at the request's own + # URL -- an infinite redirect loop, not a gate. + # 2. Nothing legitimate emits it. A path segment cannot contain a literal + # "/", so %2F inside a path is always either a probe or a proxy- + # confusion attempt, and the Apache tier has been 404ing it all along, + # so no site on the fleet can depend on it. + # + # SCOPED to paths that mention wp-admin, not all paths. A blanket "deny any + # %2F in any path" would also hit REST/API-style routes on non-WordPress + # customer apps that legitimately pass an encoded slash inside a path + # parameter. Scoping keeps the blast radius inside the attack surface this + # gate owns. + # + # Matching is on the SUBSTRING, not an anchored pattern, on purpose: + # /blog%2Fwp-admin/plugins.php hides the separator BEFORE "wp-admin", where + # an anchored (^|/)wp-admin/ never matches, and OLS still resolves it to + # /blog/wp-admin/plugins.php. Substring matching catches the separator + # wherever it is. percent-to-uppercase has already folded %2f into %2F; + # the -i is belt and braces so this rule stands on its own if the + # normalizer is ever reordered. + # + # %5C (encoded backslash) is denied on the same terms. On this fleet's + # Linux backends a backslash is an ordinary filename character, so + # /wp-admin%5Cplugins.php 404s rather than booting PHP -- measured, it is + # not a live bypass today. It is included because it is the same + # encoded-separator trick against a backend that happens to treat "\" as + # one, it costs nothing, and no legitimate path contains it. + acl wp_admin_word path -i -m sub wp-admin + acl path_has_encoded_sep path -i -m sub %2f %5c + http-request deny deny_status 403 if wp_admin_word path_has_encoded_sep !has_wp_logged_in !wp_gate_exempt !is_local !is_trusted_ip !is_whitelisted + http-request deny deny_status 403 if wp_admin_path !wp_admin_safe_path !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,i) 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_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)