Merge branch 'fix/trusted-proxy-header-gate'
This commit is contained in:
@@ -31,6 +31,8 @@ COPY haproxy_manager.py /haproxy/
|
|||||||
COPY scripts /haproxy/scripts
|
COPY scripts /haproxy/scripts
|
||||||
COPY trusted_ips.list /etc/haproxy/trusted_ips.list
|
COPY trusted_ips.list /etc/haproxy/trusted_ips.list
|
||||||
COPY trusted_ips.map /etc/haproxy/trusted_ips.map
|
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
|
# /etc/haproxy is a named volume in deployed containers, so baked-in files
|
||||||
# under that path get shadowed by the volume on existing deployments.
|
# under that path get shadowed by the volume on existing deployments.
|
||||||
# Place errorfiles outside the volumed path; the HAProxy config references
|
# Place errorfiles outside the volumed path; the HAProxy config references
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -21,6 +21,8 @@ set -eo pipefail
|
|||||||
mkdir -p /etc/haproxy
|
mkdir -p /etc/haproxy
|
||||||
[ -f /etc/haproxy/trusted_ips.list ] || : > /etc/haproxy/trusted_ips.list
|
[ -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/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 &
|
cron &
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -22,6 +22,28 @@ frontend web
|
|||||||
# Capture Host header so it appears in httplog output (in %hr field)
|
# Capture Host header so it appears in httplog output (in %hr field)
|
||||||
http-request capture req.hdr(Host) len 64
|
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
|
# Detect real client IP from proxy headers if they exist
|
||||||
# Priority: CF-Connecting-IP (Cloudflare) > X-Real-IP > X-Forwarded-For > src
|
# Priority: CF-Connecting-IP (Cloudflare) > X-Real-IP > X-Forwarded-For > src
|
||||||
acl has_cf_connecting_ip req.hdr(CF-Connecting-IP) -m found
|
acl has_cf_connecting_ip req.hdr(CF-Connecting-IP) -m found
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user