Files
haproxy-manager-base/templates/hap_header.tpl
T
shadowdaoandClaude Opus 5 e545f3b6e0 test(haproxy): harden wp-admin gate suite against comment-collision, fix two false doc claims
Adversarial mutation audit found the wp-admin gate test suite (26 tests, all
green) did not actually test the feature: 14 of 26 assertions ran bare
str.index/assertIn/re.search over the full rendered config, so they matched
this file's own explanatory comment blocks (which quote ACL names and whole
rules) just as happily as the real rule. Deleting the entire redirect rule,
or `acl wp_admin_allowed`, or all five normalizers, left the old suite at
26/26 PASS. rule_lines() also only stripped whole-comment lines, so a
trailing " # decoy" comment on a surviving line could impersonate a deleted
one, and one ordering test used bare cfg.index() which still "finds" a
normalize-uri directive that has been fully commented out (the substring
survives after the '#').

Rewrites every rule-presence/content/ordering assertion to go through
rule_lines()/rule_positions(), now truncating each line at the first ' #'
before matching, and adds require_rule()/require_position() guards so a
missing rule raises a named AssertionError instead of IndexError or
"substring not found". Adds dedicated declared-ACL tests for wp_admin_path,
wp_admin_asset, wp_admin_allowed and wp_gate_exempt so each has its own
direct, comment-safe check. 29 tests now (was 26).

Proved via a mutation harness (copy templates to a scratch dir, mutate the
copy, run the suite via HAPROXY_MANAGER_DIR, restore): commenting out the
redirect rule, either deny rule, any of the four wp_admin_* ACLs, any one of
the five normalize-uri lines, or expose-experimental-directives now reddens
the suite -- 13/13 required mutations caught, plus the exact trailing-comment
decoy and "all five normalizers commented at once" cases from the audit.

Also corrects two doc claims the audit found factually wrong:

- hap_listener.tpl: normalize-uri's percent-to-uppercase and
  percent-decode-unreserved rewrite the WHOLE request-target, not just the
  path -- measured examples included, and the query-sort-by-name rejection
  reasoning ("every rule matches path") was a non-sequitur given that. Real
  reason to leave it off: reordering would break signed/cached URLs. Fleet
  checked: no .NET backends, no URL-in-path proxies, no known victim today.

- hap_header.tpl: dropping expose-experimental-directives does not
  crash-loop the container. do_initial_setup() swallows the `haproxy -c`
  failure and start_haproxy() returns without raising, so start-up.sh execs
  gunicorn as PID 1 anyway -- a silent total outage (ports 80/443 unbound,
  every site down) that ensure_haproxy.py retries forever without
  escalating, while GET /health keeps answering 200.

No HAProxy rule, ACL, or normalizer changed -- comments and tests only.
Verified: all 5 required suites green, and `haproxy -c` against the real
haproxy 3.0.11 (Debian package) still exits 0 with only the same pre-existing
warnings as before (wp_admin_asset path_reg advisory, stats file).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 09:38:49 -07:00

134 lines
5.9 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 does NOT crash-loop the container -- the truth
# is worse: it is a SILENT TOTAL OUTAGE that nothing escalates. Container
# init (scripts/init.py -> haproxy_manager.do_initial_setup()) calls
# generate_config() (which still succeeds -- Jinja doesn't validate
# HAProxy semantics) and then start_haproxy(), which runs `haproxy -c`,
# sees it fail, logs an error, and RETURNS WITHOUT RAISING. init.py exits
# 0. scripts/start-up.sh then execs gunicorn as PID 1 regardless. Result:
# the container stays "Up", ports 80/443 are never bound, EVERY SITE ON
# THE HOST IS DOWN, and the in-container supervisor loop
# (ensure_haproxy.py, every HAPROXY_SUPERVISOR_INTERVAL seconds) retries
# the identical failing render forever without ever escalating. Worse
# still, GET /health keeps returning HTTP 200 -- health_check() only
# answers 500 on a database error; a dead haproxy just flips the JSON
# body's "haproxy_status" to "stopped" while the status code a naive
# monitor checks never changes. Do not trust /health alone to catch this.
#
# 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