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) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,16 @@ ALLOWLIST = ('/admin-ajax.php', '/admin-post.php',
|
|||||||
EXCLUSIONS = ('!wp_admin_allowed', '!wp_admin_asset', '!has_wp_logged_in',
|
EXCLUSIONS = ('!wp_admin_allowed', '!wp_admin_asset', '!has_wp_logged_in',
|
||||||
'!wp_gate_exempt', '!is_local', '!is_trusted_ip', '!is_whitelisted')
|
'!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():
|
def render_listener():
|
||||||
return haproxy_manager.template_env.get_template('hap_listener.tpl').render(
|
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):
|
class WpAdminGate(unittest.TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -176,6 +204,155 @@ class WpAdminGate(unittest.TestCase):
|
|||||||
def test_wp_admin_safe_path_acl_declared(self):
|
def test_wp_admin_safe_path_acl_declared(self):
|
||||||
self.assertRegex(self.cfg, r'acl\s+wp_admin_safe_path\s+path_reg')
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main(verbosity=2)
|
unittest.main(verbosity=2)
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ global
|
|||||||
# SSL and Performance
|
# SSL and Performance
|
||||||
tune.ssl.default-dh-param 2048
|
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
|
# HTTP/3 over QUIC. The Debian haproxy package is built against system
|
||||||
# OpenSSL via the compatibility shim (USE_QUIC_OPENSSL_COMPAT), which is
|
# OpenSSL via the compatibility shim (USE_QUIC_OPENSSL_COMPAT), which is
|
||||||
# not a native QUIC TLS stack. HAProxy therefore rejects `quic*@` binds
|
# not a native QUIC TLS stack. HAProxy therefore rejects `quic*@` binds
|
||||||
|
|||||||
+186
-20
@@ -22,6 +22,83 @@ frontend web
|
|||||||
# Capture Host header so it appears in httplog output (in %hr field)
|
# Capture Host header so it appears in httplog output (in %hr field)
|
||||||
http-request capture req.hdr(Host) len 64
|
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) ---
|
# --- Trusted-proxy gate (MUST precede real-IP resolution below) ---
|
||||||
# CF-Connecting-IP / X-Real-IP / X-Forwarded-For are client-supplied. Any
|
# 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
|
# 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
|
# via /etc/haproxy/wpadmin_gate_exempt.list (operator-managed, seeded
|
||||||
# empty by start-up.sh) for sites where a plugin legitimately serves
|
# empty by start-up.sh) for sites where a plugin legitimately serves
|
||||||
# unauthenticated visitors from a /wp-admin/ URL outside this allowlist.
|
# 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
|
# wp_admin_safe_path guards against an OPEN REDIRECT this gate would
|
||||||
# otherwise introduce. The redirect target below is built by rewriting
|
# otherwise introduce. The redirect target below is built by rewriting
|
||||||
# `path` with regsub -- regsub only replaces the matched substring, so
|
# `path` with regsub -- regsub only replaces the matched substring, so
|
||||||
# everything BEFORE the matched "/wp-admin/" survives untouched in the
|
# everything BEFORE the matched "/wp-admin/" survives untouched in the
|
||||||
# output. `path` is not guaranteed to be a clean site-relative string;
|
# output. `path` is not guaranteed to be a clean site-relative string;
|
||||||
# three concrete requests turn that survival into an off-site
|
# 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
|
# //evil.example.com/wp-admin/x.php -> //evil.example.com/wp-login.php
|
||||||
# (protocol-relative -- browsers resolve "//host/path" to
|
# (protocol-relative -- browsers resolve "//host/path" to
|
||||||
# "https://host/path", so this redirects off-site with no scheme
|
# "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
|
# /\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
|
# 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`
|
# (RFC 7230 absolute-form request targets can make HAProxy's `path`
|
||||||
# fetch return a full URI, not just the path component)
|
# fetch return a full URI, not just the path component). HAProxy's own
|
||||||
# None of these vectors reach WordPress today -- this gate is what would
|
# H1 parser answers 400 on this frontend; this ACL is the backstop.
|
||||||
# newly expose them as a phishing primitive on every customer domain on
|
# So wp_admin_safe_path is NOT redundant with the normalisation and must
|
||||||
# the fleet. wp_admin_safe_path requires a well-formed absolute path
|
# not be deleted as such -- one of its three vectors survives normalisation
|
||||||
# (leading "/" not followed by another "/" or a backslash) and is a
|
# untouched.
|
||||||
# POSITIVE condition on the redirect rule, not a negation: a path that
|
#
|
||||||
# fails it simply is not redirected and falls through to the backend --
|
# It is used TWO ways, and the pair matters:
|
||||||
# today's (pre-gate) behavior, so failing the check is never a
|
# - as a POSITIVE condition on the redirect, so a pathological path can
|
||||||
# regression, only a missed redirect on a pathological input. DO NOT
|
# never produce a `Location:` header at all; and
|
||||||
# remove this ACL as redundant with wp_admin_path -- wp_admin_path's
|
# - as an explicit deny, so such a path is not merely un-redirected.
|
||||||
# `path_reg (^|/)wp-admin/` happily matches all three vectors above.
|
# The deny is what closes the failure mode the positive-condition form
|
||||||
acl wp_admin_path path_reg (^|/)wp-admin/
|
# 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
|
# Four literal backslashes here is NOT a typo. HAProxy's config-line word
|
||||||
# parser treats backslash as its OWN escape character before the value
|
# parser treats backslash as its OWN escape character before the value
|
||||||
# ever reaches the regex engine: "\\" (two backslashes) in the config
|
# ever reaches the regex engine: "\\" (two backslashes) in the config
|
||||||
@@ -326,14 +438,68 @@ frontend web
|
|||||||
# "missing terminating ] for character class" -- verified against real
|
# "missing terminating ] for character class" -- verified against real
|
||||||
# HAProxy 3.0.11. Four backslashes ("\\\\") collapse to two ("\\"),
|
# HAProxy 3.0.11. Four backslashes ("\\\\") collapse to two ("\\"),
|
||||||
# which PCRE then reads as a single escaped-backslash class member --
|
# which PCRE then reads as a single escaped-backslash class member --
|
||||||
# the intended "reject a literal backslash" semantics. Confirmed live:
|
# the intended "reject a literal backslash" semantics.
|
||||||
# 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_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_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
|
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
|
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)
|
# IP blocking using map file (manual blocks only)
|
||||||
|
|||||||
Reference in New Issue
Block a user