From 68ad83a3314b2c8030f4bcc9012076317881c3e9 Mon Sep 17 00:00:00 2001 From: Zo Bot Date: Thu, 2 Jul 2026 05:28:50 +0000 Subject: [PATCH] is_valid_ip: accept RFC 3986 bracketed IPv6 addresses Some load balancers (e.g. Fortigate) emit IPv6 addresses in X-Forwarded-For in the URI-reference bracketed form, e.g. '[2620:0:d60:ac1a::10]'. The existing is_valid_ip check calls getaddrinfo, which rejects the bracketed form with EAI_NONAME, so these forwarded addresses were silently dropped and the request fell back to the connection peer. Strip the brackets inside is_valid_ip so the bracketed form is accepted and validated as a normal IPv6 address, and unwrap the brackets in _apply_xheaders so the stored remote_ip is always the bare address. Fixes #3561. --- tornado/httpserver.py | 4 ++++ tornado/netutil.py | 8 +++++++- tornado/test/httpserver_test.py | 16 ++++++++++++++++ tornado/test/netutil_test.py | 9 +++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/tornado/httpserver.py b/tornado/httpserver.py index 98e0a9eda..8c13fc47c 100644 --- a/tornado/httpserver.py +++ b/tornado/httpserver.py @@ -346,6 +346,10 @@ def _apply_xheaders(self, headers: httputil.HTTPHeaders) -> None: break ip = headers.get("X-Real-Ip", ip) if netutil.is_valid_ip(ip): + # Unwrap the RFC 3986 bracketed IPv6 form so the stored + # remote_ip is always the bare address. + if len(ip) >= 2 and ip[0] == "[" and ip[-1] == "]": + ip = ip[1:-1] self.remote_ip = ip # AWS uses X-Forwarded-Proto proto_header = headers.get( diff --git a/tornado/netutil.py b/tornado/netutil.py index d9e722eff..a22cc2edf 100644 --- a/tornado/netutil.py +++ b/tornado/netutil.py @@ -293,12 +293,18 @@ def remove_handler() -> None: def is_valid_ip(ip: str) -> bool: """Returns ``True`` if the given string is a well-formed IP address. - Supports IPv4 and IPv6. + Supports IPv4 and IPv6. IPv6 addresses wrapped in ``[`` and ``]`` (as + used in URI references per RFC 3986) are also accepted. """ if not ip or "\x00" in ip: # getaddrinfo resolves empty strings to localhost, and truncates # on zero bytes. return False + # Accept the RFC 3986 URI form for IPv6 addresses, e.g. "[::1]". + if len(ip) >= 2 and ip[0] == "[" and ip[-1] == "]": + ip = ip[1:-1] + if not ip: + return False try: res = socket.getaddrinfo( ip, 0, socket.AF_UNSPEC, socket.SOCK_STREAM, 0, socket.AI_NUMERICHOST diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py index 02d4f4c09..e96e005a9 100644 --- a/tornado/test/httpserver_test.py +++ b/tornado/test/httpserver_test.py @@ -698,6 +698,22 @@ def test_ip_headers(self): "2620:0:1cfe:face:b00c::3", ) + # Some load balancers (e.g. Fortigate) emit IPv6 in the RFC 3986 + # bracketed URI form. The bracketed form should be accepted and + # unwrapped to the bare address. + bracketed_ipv6 = {"X-Forwarded-For": "[2620:0:1cfe:face:b00c::3]"} + self.assertEqual( + self.fetch_json("/", headers=bracketed_ipv6)["remote_ip"], + "2620:0:1cfe:face:b00c::3", + ) + bracketed_ipv6_in_list = { + "X-Forwarded-For": "::1, [2620:0:1cfe:face:b00c::3]" + } + self.assertEqual( + self.fetch_json("/", headers=bracketed_ipv6_in_list)["remote_ip"], + "2620:0:1cfe:face:b00c::3", + ) + invalid_chars = {"X-Real-IP": "4.4.4.4