From af9fb1d2f04867e2d7482c90ed863c6c7e6bdf14 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 13 Aug 2026 13:23:52 -0700 Subject: [PATCH 1/2] feat(haproxy): ship trusted-proxy source lists for header gating --- Dockerfile | 2 ++ cloudflare_ips.list | 34 ++++++++++++++++++++++++++++++++++ scripts/start-up.sh | 2 ++ trusted_proxies.list | 13 +++++++++++++ 4 files changed, 51 insertions(+) create mode 100644 cloudflare_ips.list create mode 100644 trusted_proxies.list diff --git a/Dockerfile b/Dockerfile index 8223bef..7b00256 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,8 @@ COPY haproxy_manager.py /haproxy/ COPY scripts /haproxy/scripts COPY trusted_ips.list /etc/haproxy/trusted_ips.list COPY trusted_ips.map /etc/haproxy/trusted_ips.map +COPY cloudflare_ips.list /etc/haproxy/cloudflare_ips.list +COPY trusted_proxies.list /etc/haproxy/trusted_proxies.list # /etc/haproxy is a named volume in deployed containers, so baked-in files # under that path get shadowed by the volume on existing deployments. # Place errorfiles outside the volumed path; the HAProxy config references diff --git a/cloudflare_ips.list b/cloudflare_ips.list new file mode 100644 index 0000000..b3f9b17 --- /dev/null +++ b/cloudflare_ips.list @@ -0,0 +1,34 @@ +# Cloudflare edge ranges — peers allowed to set CF-Connecting-IP / X-Real-IP / +# X-Forwarded-For. Referenced by templates/hap_listener.tpl: +# acl from_trusted_proxy src -f /etc/haproxy/cloudflare_ips.list -f /etc/haproxy/trusted_proxies.list +# +# PUBLIC DATA — safe to commit. Source: https://www.cloudflare.com/ips-v4 +# and https://www.cloudflare.com/ips-v6. Keep in sync with the IPv4 snapshot in +# WHP's ssl_renewal_orchestrator.php::isCloudflareIP() (that one is IPv4-only). +# Refresh at release time; Cloudflare has not added a range since 2021. +# +# IPv4 +173.245.48.0/20 +103.21.244.0/22 +103.22.200.0/22 +103.31.4.0/22 +141.101.64.0/18 +108.162.192.0/18 +190.93.240.0/20 +188.114.96.0/20 +197.234.240.0/22 +198.41.128.0/17 +162.158.0.0/15 +104.16.0.0/13 +104.24.0.0/14 +172.64.0.0/13 +131.0.72.0/22 +# +# IPv6 +2400:cb00::/32 +2606:4700::/32 +2803:f800::/32 +2405:b500::/32 +2405:8100::/32 +2a06:98c0::/29 +2c0f:f248::/32 diff --git a/scripts/start-up.sh b/scripts/start-up.sh index 1e782e2..fc648c9 100755 --- a/scripts/start-up.sh +++ b/scripts/start-up.sh @@ -21,6 +21,8 @@ set -eo pipefail mkdir -p /etc/haproxy [ -f /etc/haproxy/trusted_ips.list ] || : > /etc/haproxy/trusted_ips.list [ -f /etc/haproxy/trusted_ips.map ] || : > /etc/haproxy/trusted_ips.map +[ -f /etc/haproxy/cloudflare_ips.list ] || : > /etc/haproxy/cloudflare_ips.list +[ -f /etc/haproxy/trusted_proxies.list ] || : > /etc/haproxy/trusted_proxies.list cron & diff --git a/trusted_proxies.list b/trusted_proxies.list new file mode 100644 index 0000000..444a0e7 --- /dev/null +++ b/trusted_proxies.list @@ -0,0 +1,13 @@ +# Additional trusted reverse proxies — peers permitted to set CF-Connecting-IP, +# X-Real-IP and X-Forwarded-For. Anything NOT matched here or in +# cloudflare_ips.list has those headers stripped before real-IP resolution. +# +# Referenced by templates/hap_listener.tpl. +# +# Leave EMPTY unless a real proxy sits in front of HAProxy on this host. Adding +# a range here lets that peer assert any client identity, which bypasses rate +# limits, IP blocks and the WAF for it. +# +# Do NOT commit real IPs — this repo is mirrored publicly. Add entries directly +# on the server; the file lives in the /etc/haproxy named volume and persists +# across container recreates. From 79a1b84ca20710cb7188f3bd9b495c03912c4c79 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 13 Aug 2026 13:28:31 -0700 Subject: [PATCH 2/2] fix(haproxy): only honour proxy headers from trusted sources Restrict CF-Connecting-IP, X-Real-IP and X-Forwarded-For to peers matching cloudflare_ips.list or trusted_proxies.list; other peers fall through to src. Adds a regression test pinning the strip-before-resolve ordering. --- scripts/test-trusted-proxy-gate.py | 88 ++++++++++++++++++++++++++++++ templates/hap_listener.tpl | 22 ++++++++ 2 files changed, 110 insertions(+) create mode 100644 scripts/test-trusted-proxy-gate.py diff --git a/scripts/test-trusted-proxy-gate.py b/scripts/test-trusted-proxy-gate.py new file mode 100644 index 0000000..51eb75a --- /dev/null +++ b/scripts/test-trusted-proxy-gate.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Regression tests for the trusted-proxy header gate in hap_listener.tpl. + +Why this file exists +-------------------- +txn.real_ip was derived from CF-Connecting-IP / X-Real-IP / X-Forwarded-For +with no check on the peer, so any direct client could assert any client IP. +That variable drives rate limiting, the trusted-IP whitelist, the wp-login +brute-force table and cookie challenge, the wp-json/batch/v1 virtual patch, +IP blocking, and Coraza's src-ip -- so a spoofed header bypassed all of them. + +These tests pin the invariant that the header strip is rendered BEFORE any +real-IP resolution. Ordering is the whole fix: a del-header emitted after the +set-var chain would parse fine, validate fine, and do nothing. + +Running +------- + python3 scripts/test-trusted-proxy-gate.py +""" + +import os +import re +import sys +import logging +import tempfile +import unittest + +MODULE_DIR = os.path.abspath( + os.environ.get('HAPROXY_MANAGER_DIR', + os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')) +) +os.chdir(MODULE_DIR) +sys.path.insert(0, MODULE_DIR) + +_LOG_DIR = tempfile.mkdtemp(prefix='haproxy-mgr-test-logs-') +_real_file_handler = logging.FileHandler +logging.FileHandler = ( + lambda filename, *a, **kw: _real_file_handler( + os.path.join(_LOG_DIR, os.path.basename(filename)), *a, **kw) +) + +import haproxy_manager # noqa: E402 + +GATED_HEADERS = ('CF-Connecting-IP', 'X-Real-IP', 'X-Forwarded-For') + + +def render_listener(): + return haproxy_manager.template_env.get_template('hap_listener.tpl').render( + crt_path='/etc/haproxy/certs', + suspension_enabled=False, + coraza_spoe_backend=None, + ) + + +class TrustedProxyGate(unittest.TestCase): + + def setUp(self): + self.cfg = render_listener() + + def test_trusted_proxy_acl_is_defined(self): + self.assertIn('acl from_trusted_proxy src', self.cfg) + self.assertIn('/etc/haproxy/cloudflare_ips.list', self.cfg) + self.assertIn('/etc/haproxy/trusted_proxies.list', self.cfg) + + def test_each_header_is_deleted_for_untrusted_peers(self): + for header in GATED_HEADERS: + with self.subTest(header=header): + pattern = (r'http-request\s+del-header\s+%s\s+if\s+!from_trusted_proxy' + % re.escape(header)) + self.assertRegex(self.cfg, pattern) + + def test_strip_precedes_real_ip_resolution(self): + """The ordering invariant. A strip after the set-var chain is a no-op.""" + last_strip = max( + self.cfg.index('del-header %s' % h) for h in GATED_HEADERS + ) + first_setvar = self.cfg.index('set-var(txn.real_ip)') + self.assertLess( + last_strip, first_setvar, + 'del-header rules must be rendered before set-var(txn.real_ip)') + + def test_src_fallback_still_present(self): + """Direct clients must fall through to the real TCP peer.""" + self.assertIn('set-var(txn.real_ip) src', self.cfg) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/templates/hap_listener.tpl b/templates/hap_listener.tpl index bf0e6fd..6a9ff27 100644 --- a/templates/hap_listener.tpl +++ b/templates/hap_listener.tpl @@ -22,6 +22,28 @@ frontend web # Capture Host header so it appears in httplog output (in %hr field) http-request capture req.hdr(Host) len 64 + # --- Trusted-proxy gate (MUST precede real-IP resolution below) --- + # CF-Connecting-IP / X-Real-IP / X-Forwarded-For are client-supplied. Any + # peer that is not a known reverse proxy gets them stripped, so the + # set-var chain below falls through to `src` -- the real TCP peer. + # + # Without this, a direct client dictates txn.real_ip, and every control + # keyed on that variable trusts the attacker's own claim: rate limiting + # (track-sc0), the trusted-IP whitelist, the wp-login brute-force table and + # cookie challenge, the wp-json/batch/v1 virtual patch, IP blocking, and + # Coraza's src-ip. Spoofing a whitelisted IP bypassed all of them. + # + # ORDER MATTERS: these must come before the set-var lines. HAProxy applies + # http-request rules in file order, so a strip placed afterwards would + # validate cleanly and accomplish nothing. + # + # Cloudflare-fronted domains keep working: CF's edge matches + # from_trusted_proxy, so its CF-Connecting-IP survives. + acl from_trusted_proxy src -f /etc/haproxy/cloudflare_ips.list -f /etc/haproxy/trusted_proxies.list + http-request del-header CF-Connecting-IP if !from_trusted_proxy + http-request del-header X-Real-IP if !from_trusted_proxy + http-request del-header X-Forwarded-For if !from_trusted_proxy + # Detect real client IP from proxy headers if they exist # Priority: CF-Connecting-IP (Cloudflare) > X-Real-IP > X-Forwarded-For > src acl has_cf_connecting_ip req.hdr(CF-Connecting-IP) -m found