Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
68 changes: 66 additions & 2 deletions tests/tools/test_url_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,19 +153,83 @@ 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."""

@pytest.mark.parametrize("ip_str", [
"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",
Expand Down
18 changes: 18 additions & 0 deletions tools/url_safety.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import ipaddress
import logging
import os
import socket
from urllib.parse import urlparse

Expand All @@ -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:
Expand Down
Loading