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:
2026-08-14 09:09:35 -07:00
co-authored by Claude Opus 5
parent 18750861b4
commit 8a8d9c5fe3
3 changed files with 378 additions and 20 deletions
+15
View File
@@ -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