Files
haproxy-manager-base/templates/hap_header.tpl
T
shadowdaoandClaude Opus 5 8a8d9c5fe3 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>
2026-08-14 09:09:35 -07:00

119 lines
4.8 KiB
Smarty

#---------------------------------------------------------------------
# Global settings
#---------------------------------------------------------------------
global
# to have these messages end up in /var/log/haproxy.log you will
# need to:
#
# 1) configure syslog to accept network log events. This is done
# by adding the '-r' option to the SYSLOGD_OPTIONS in
# /etc/sysconfig/syslog
#
# 2) configure local2 events to go to the /var/log/haproxy.log
# file. A line like the following can be added to
# /etc/sysconfig/syslog
#
# local2.* /var/log/haproxy.log
#
log 127.0.0.1 local2
chroot /var/lib/haproxy
pidfile /var/run/haproxy.pid
maxconn 4000
user haproxy
group haproxy
daemon
# 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
# unless this opt-in is set. `limited-quic` enables QUIC through the compat
# layer (no 0-RTT — that needs quictls/aws-lc or native OpenSSL 3.5 QUIC).
# Without this, the quic bind in the frontend fails to start: "this SSL
# library does not support the QUIC protocol".
limited-quic
{%- if cluster_secret %}
# Stable secret keying QUIC Retry/address-validation tokens. Self-healed
# to /etc/haproxy/cluster-secret (named volume) by the manager so it
# survives recreates; without it haproxy picks a random one per process
# and tokens don't survive reloads (benign, just a startup notice).
cluster-secret "{{ cluster_secret }}"
{%- endif %}
# HTTP/2 protection against Rapid Reset (CVE-2023-44487) and stream abuse
tune.h2.fe.max-total-streams 2000
tune.h2.fe.glitches-threshold 50
# Stats persistence for zero-downtime reloads
stats-file /var/lib/haproxy/stats.dat
#---------------------------------------------------------------------
# DNS resolver for Docker container name resolution
# Re-resolves backend server addresses so container IP changes
# (from restarts, recreations, scaling) are picked up automatically
#---------------------------------------------------------------------
resolvers docker_dns
nameserver dns1 127.0.0.11:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
hold valid 10s
hold other 10s
hold refused 10s
hold nx 10s
hold timeout 10s
hold obsolete 10s
#---------------------------------------------------------------------
# common defaults that all the 'listen' and 'backend' sections will
# use if not designated in their block
#---------------------------------------------------------------------
defaults
mode http
log global
option httplog
option dontlognull
option http-server-close
option forwardfor #except 127.0.0.0/8
option redispatch
retries 3
timeout http-request 30s
timeout queue 2m
timeout connect 10s
timeout client 5m
timeout server 10m
timeout http-keep-alive 30s
timeout check 10s
timeout tarpit 10s # Tarpit delay for low-level scanners (before silent-drop)
maxconn 3000
# Per-request unique reference, used:
# - in the log line (httplog includes %ID)
# - echoed to clients in the X-Request-Reference response header on
# WAF blocks so a customer can quote it when opening a support ticket
# - embedded in /etc/haproxy/errors/403-waf.html so a blocked visitor
# sees it on the rendered 403 page
# Support correlates ref → /var/log/haproxy.log line → timestamp+client+host
# → /var/log/coraza/audit.log entry → rule_id.
unique-id-format %[uuid()]
unique-id-header X-Request-Reference