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>
134 lines
5.9 KiB
Smarty
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
|
|
|