From e52ac46e297d9d483ad8cf16e43487545185a997 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Fri, 23 Jan 2026 11:09:26 +0100 Subject: [PATCH] feat: support CIDR networks in forwarded_allow_ips and proxy_allow_ips Use Python's ipaddress module to support IP networks in allow lists. Individual IP addresses are converted to /32 (IPv4) or /128 (IPv6) networks. CIDR notation (e.g., 192.168.0.0/16) is now supported. Fixes #1485 Closes #2390 --- gunicorn/config.py | 20 +++++++++++++++----- gunicorn/http/message.py | 27 +++++++++++++++++++++------ tests/test_config.py | 27 ++++++++++++++++++++++++--- 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/gunicorn/config.py b/gunicorn/config.py index a1e76796..caa7ab0e 100644 --- a/gunicorn/config.py +++ b/gunicorn/config.py @@ -407,12 +407,15 @@ def validate_list_of_existing_files(val): def validate_string_to_addr_list(val): val = validate_string_to_list(val) + result = [] for addr in val: if addr == "*": + result.append(addr) continue - _vaid_ip = ipaddress.ip_address(addr) + # Support both single IPs and CIDR networks + result.append(ipaddress.ip_network(addr, strict=False)) - return val + return result def validate_string_to_list(val): @@ -1278,8 +1281,11 @@ class ForwardedAllowIPS(Setting): validator = validate_string_to_addr_list default = os.environ.get("FORWARDED_ALLOW_IPS", "127.0.0.1,::1") desc = """\ - Front-end's IPs from which allowed to handle set secure headers. - (comma separated). + Front-end's IP addresses or networks from which allowed to handle + set secure headers. (comma separated). + + Supports both individual IP addresses (e.g., ``192.168.1.1``) and + CIDR networks (e.g., ``192.168.0.0/16``). Set to ``*`` to disable checking of front-end IPs. This is useful for setups where you don't know in advance the IP address of front-end, but @@ -2094,7 +2100,11 @@ class ProxyAllowFrom(Setting): validator = validate_string_to_addr_list default = "127.0.0.1,::1" desc = """\ - Front-end's IPs from which allowed accept proxy requests (comma separated). + Front-end's IP addresses or networks from which allowed accept + proxy requests (comma separated). + + Supports both individual IP addresses (e.g., ``192.168.1.1``) and + CIDR networks (e.g., ``192.168.0.0/16``). Set to ``*`` to disable checking of front-end IPs. This is useful for setups where you don't know in advance the IP address of front-end, but diff --git a/gunicorn/http/message.py b/gunicorn/http/message.py index 59ce0bf4..4e8dd444 100644 --- a/gunicorn/http/message.py +++ b/gunicorn/http/message.py @@ -3,6 +3,7 @@ # See the NOTICE for more information. import io +import ipaddress import re import socket @@ -30,6 +31,22 @@ VERSION_RE = re.compile(r"HTTP/(\d)\.(\d)") RFC9110_5_5_INVALID_AND_DANGEROUS = re.compile(r"[\0\r\n]") +def _ip_in_allow_list(ip_str, allow_list): + """Check if IP address is in the allow list (which may contain networks).""" + if '*' in allow_list: + return True + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + return False + for network in allow_list: + if network == '*': + return True + if ip in network: + return True + return False + + class Message: def __init__(self, cfg, unreader, peer_addr): self.cfg = cfg @@ -82,9 +99,8 @@ class Message: # nonsense. either a request is https from the beginning # .. or we are just behind a proxy who does not remove conflicting trailers pass - elif ('*' in cfg.forwarded_allow_ips or - not isinstance(self.peer_addr, tuple) - or self.peer_addr[0] in cfg.forwarded_allow_ips): + elif (not isinstance(self.peer_addr, tuple) + or _ip_in_allow_list(self.peer_addr[0], cfg.forwarded_allow_ips)): secure_scheme_headers = cfg.secure_scheme_headers forwarder_headers = cfg.forwarder_headers @@ -352,9 +368,8 @@ class Request(Message): def proxy_protocol_access_check(self): # check in allow list - if ("*" not in self.cfg.proxy_allow_ips and - isinstance(self.peer_addr, tuple) and - self.peer_addr[0] not in self.cfg.proxy_allow_ips): + if (isinstance(self.peer_addr, tuple) and + not _ip_in_allow_list(self.peer_addr[0], self.cfg.proxy_allow_ips)): raise ForbiddenProxyRequest(self.peer_addr[0]) def parse_proxy_protocol(self, line): diff --git a/tests/test_config.py b/tests/test_config.py index 6ca014b6..0aff90f5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ # This file is part of gunicorn released under the MIT license. # See the NOTICE for more information. +import ipaddress import os import re import sys @@ -165,10 +166,30 @@ def test_str_validation(): def test_str_to_addr_list_validation(): c = config.Config() - assert c.proxy_allow_ips == ["127.0.0.1", "::1"] - assert c.forwarded_allow_ips == ["127.0.0.1", "::1"] + # Default values are now network objects + assert c.proxy_allow_ips == [ + ipaddress.ip_network("127.0.0.1/32"), + ipaddress.ip_network("::1/128") + ] + assert c.forwarded_allow_ips == [ + ipaddress.ip_network("127.0.0.1/32"), + ipaddress.ip_network("::1/128") + ] + # Single IPs are converted to /32 or /128 networks c.set("forwarded_allow_ips", "127.0.0.1,192.0.2.1") - assert c.forwarded_allow_ips == ["127.0.0.1", "192.0.2.1"] + assert c.forwarded_allow_ips == [ + ipaddress.ip_network("127.0.0.1/32"), + ipaddress.ip_network("192.0.2.1/32") + ] + # CIDR networks are supported + c.set("forwarded_allow_ips", "127.0.0.0/8,192.168.0.0/16") + assert c.forwarded_allow_ips == [ + ipaddress.ip_network("127.0.0.0/8"), + ipaddress.ip_network("192.168.0.0/16") + ] + # Wildcard is preserved as string + c.set("forwarded_allow_ips", "*") + assert c.forwarded_allow_ips == ["*"] c.set("forwarded_allow_ips", "") assert c.forwarded_allow_ips == [] c.set("forwarded_allow_ips", None)