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>
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>
Two defects in the wp-admin edge gate (2171bed, 704be38):
1. HAProxy 3.0.11 rejects the inline regsub redirect
(regsub((^|/)wp-admin/.*,\1wp-login.php)) with "invalid arg 2 in
converter 'regsub': missing arguments". Verified this is a
converter-argument-parenthesis-counting limitation -- the inner
"(^|/)" grouping parens are misread as closing the outer regsub()
call, and neither quoting nor backslash-escaping the parens helps.
Since HTTP paths always start with "/", the group is unnecessary:
compute the login URL in its own set-var, matching the literal
substring "/wp-admin/" (no group, no backreference) and replacing
it with the literal "/wp-login.php" -- regsub only replaces the
matched substring, so a subdirectory-install prefix survives
untouched.
2. wp_admin_allowed used a bare path_end suffix match
(/admin-ajax.php etc), so /wp-admin/evil/admin-ajax.php matched
both wp_admin_path and the allowlist and sailed through the gate
ungated. Anchored each entry to /wp-admin/<file>.
Verified against real HAProxy 3.0.11-1+deb13u3: haproxy -c exit 0,
and live curl against the real generated config's literal lines
confirms root-install and subdirectory-install redirects, the
anchored-allowlist fix, cookie exemption, and non-wp-admin passthrough
all behave correctly.
Extends scripts/test-wpadmin-gate.py with regression tests for the
anchored allowlist and the set-var ordering/no-inline-regsub guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Redirect /wp-admin/* to the site's login page when no wordpress_logged_in_
cookie is present, so unauthenticated requests never boot PHP. Identity-based
rather than rate-based, so it is unaffected by how widely an attack is
distributed. Allowlists the paths that legitimately serve unauthenticated
visitors, including the css/js the login page itself loads.