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