From a2427ac275cf8cb8736b07d1f2a68076fa005196 Mon Sep 17 00:00:00 2001 From: Dilee Date: Sun, 29 Mar 2026 23:03:05 +0300 Subject: [PATCH] fix(security): allow RFC 2544 range bypass for TUN-mode proxy / Fake-IP users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 3.11 expanded ipaddress.is_private to cover all IANA special-purpose ranges, including 198.18.0.0/15 (RFC 2544 Benchmarking). This caused a regression for users running TUN-mode proxy software (Clash, Mihomo, Sing-box, Surge) that uses this range as a Fake-IP pool — DNS returns a virtual 198.18.x.x address, but actual traffic is forwarded by the TUN interface to the real public destination. SSRF protection blocked these requests with "private address". Adds HERMES_ALLOW_RFC2544=true env var to unblock the range. Secure by default: the range remains blocked unless explicitly opted in. Documents the env var in config.py alongside other security-relevant settings. Fixes #3777 --- hermes_cli/config.py | 16 ++++++++ tests/tools/test_url_safety.py | 68 +++++++++++++++++++++++++++++++++- tools/url_safety.py | 18 +++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7486f34b985e..b6768f535623 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -928,6 +928,22 @@ def ensure_hermes_home(): "category": "messaging", }, + # ── Security ── + "HERMES_ALLOW_RFC2544": { + "description": ( + "Allow the RFC 2544 Benchmarking range (198.18.0.0/15) to bypass SSRF protection. " + "Enable this if you use TUN-mode proxy software (Clash, Mihomo, Sing-box, Surge) " + "with Fake-IP, where DNS returns virtual 198.18.x.x addresses that are forwarded " + "to real public destinations by the TUN interface. " + "Python 3.11+ incorrectly classifies this range as private. " + "Do NOT enable on machines without a TUN proxy intercepting this range." + ), + "prompt": "Allow RFC 2544 Fake-IP range (true/false, for TUN proxy users)", + "url": None, + "password": False, + "category": "setting", + }, + # ── Agent settings ── "MESSAGING_CWD": { "description": "Working directory for terminal commands via messaging", diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 6a2de78f6a7e..73d8c5ed8d2b 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -153,6 +153,62 @@ def test_non_cgnat_100_allowed(self): assert is_safe_url("http://legit-host.example/") is True +class TestRfc2544FakeIp: + """RFC 2544 / Fake-IP range behaviour (Python 3.11 regression).""" + + def test_rfc2544_blocked_by_default(self): + """198.18.0.0/15 must be blocked when HERMES_ALLOW_RFC2544 is not set.""" + import tools.url_safety as safety + original = safety._ALLOW_RFC2544 + try: + safety._ALLOW_RFC2544 = False + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("198.18.4.114", 0)), + ]): + assert is_safe_url("https://www.baidu.com") is False + finally: + safety._ALLOW_RFC2544 = original + + def test_rfc2544_allowed_when_env_enabled(self): + """198.18.0.0/15 must be allowed when HERMES_ALLOW_RFC2544=true.""" + import tools.url_safety as safety + original = safety._ALLOW_RFC2544 + try: + safety._ALLOW_RFC2544 = True + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("198.18.4.114", 0)), + ]): + assert is_safe_url("https://www.baidu.com") is True + finally: + safety._ALLOW_RFC2544 = original + + def test_rfc2544_upper_bound_allowed_when_env_enabled(self): + """Upper end of range (198.19.255.254) also unblocked.""" + import tools.url_safety as safety + original = safety._ALLOW_RFC2544 + try: + safety._ALLOW_RFC2544 = True + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("198.19.255.254", 0)), + ]): + assert is_safe_url("https://github.com") is True + finally: + safety._ALLOW_RFC2544 = original + + def test_private_rfc1918_still_blocked_when_rfc2544_allowed(self): + """Enabling RFC 2544 must not unblock real private ranges.""" + import tools.url_safety as safety + original = safety._ALLOW_RFC2544 + try: + safety._ALLOW_RFC2544 = True + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("192.168.1.1", 0)), + ]): + assert is_safe_url("http://router.local") is False + finally: + safety._ALLOW_RFC2544 = original + + class TestIsBlockedIp: """Direct tests for the _is_blocked_ip helper.""" @@ -160,12 +216,20 @@ class TestIsBlockedIp: "127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0", "224.0.0.1", "255.255.255.255", "100.64.0.1", "100.100.100.100", "100.127.255.254", + # RFC 2544 blocked by default + "198.18.0.1", "198.18.4.114", "198.19.255.254", "::1", "fe80::1", "fc00::1", "fd12::1", "ff02::1", "::ffff:127.0.0.1", "::ffff:169.254.169.254", ]) def test_blocked_ips(self, ip_str): - ip = ipaddress.ip_address(ip_str) - assert _is_blocked_ip(ip) is True, f"{ip_str} should be blocked" + import tools.url_safety as safety + original = safety._ALLOW_RFC2544 + try: + safety._ALLOW_RFC2544 = False + ip = ipaddress.ip_address(ip_str) + assert _is_blocked_ip(ip) is True, f"{ip_str} should be blocked" + finally: + safety._ALLOW_RFC2544 = original @pytest.mark.parametrize("ip_str", [ "8.8.8.8", "93.184.216.34", "1.1.1.1", "100.0.0.1", diff --git a/tools/url_safety.py b/tools/url_safety.py index ae610d0f7819..98fbd654d5d2 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -17,6 +17,7 @@ import ipaddress import logging +import os import socket from urllib.parse import urlparse @@ -34,9 +35,26 @@ # VPNs, and some cloud internal networks. _CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10") +# 198.18.0.0/15 (RFC 2544 Benchmarking Test Range). +# Python 3.11 expanded ipaddress.is_private to cover all IANA special-purpose +# ranges, including this one. As a result, modern proxy software (Clash, +# Mihomo, Sing-box, Surge) that uses this range as a Fake-IP pool in TUN mode +# is incorrectly blocked — DNS returns a virtual 198.18.x.x address, but the +# actual traffic is forwarded by the TUN interface to the real public destination. +# Set HERMES_ALLOW_RFC2544=true to unblock this range for TUN-mode proxy users. +_RFC2544_BENCHMARK = ipaddress.ip_network("198.18.0.0/15") +_ALLOW_RFC2544 = os.getenv("HERMES_ALLOW_RFC2544", "").lower() in ("true", "1", "yes") + def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool: """Return True if the IP should be blocked for SSRF protection.""" + # Allow RFC 2544 range when explicitly enabled (TUN-mode proxy / Fake-IP) + if ( + _ALLOW_RFC2544 + and isinstance(ip, ipaddress.IPv4Address) + and ip in _RFC2544_BENCHMARK + ): + return False if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: return True if ip.is_multicast or ip.is_unspecified: