mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-25 20:36:00 +00:00
fix(security): add ssrfWhitelist config to unblock Tailscale/CGNAT (#2669)
This commit is contained in:
parent
193eccdac7
commit
5f08d61d8f
@ -37,17 +37,27 @@ def load_config(config_path: Path | None = None) -> Config:
|
|||||||
"""
|
"""
|
||||||
path = config_path or get_config_path()
|
path = config_path or get_config_path()
|
||||||
|
|
||||||
|
config = Config()
|
||||||
if path.exists():
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
data = _migrate_config(data)
|
data = _migrate_config(data)
|
||||||
return Config.model_validate(data)
|
config = Config.model_validate(data)
|
||||||
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
|
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
|
||||||
logger.warning(f"Failed to load config from {path}: {e}")
|
logger.warning(f"Failed to load config from {path}: {e}")
|
||||||
logger.warning("Using default configuration.")
|
logger.warning("Using default configuration.")
|
||||||
|
|
||||||
return Config()
|
_apply_ssrf_whitelist(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_ssrf_whitelist(config: Config) -> None:
|
||||||
|
"""Apply SSRF whitelist from config to the network security module."""
|
||||||
|
if config.tools.ssrf_whitelist:
|
||||||
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
|
configure_ssrf_whitelist(config.tools.ssrf_whitelist)
|
||||||
|
|
||||||
|
|
||||||
def save_config(config: Config, config_path: Path | None = None) -> None:
|
def save_config(config: Config, config_path: Path | None = None) -> None:
|
||||||
|
|||||||
@ -192,6 +192,7 @@ class ToolsConfig(Base):
|
|||||||
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
||||||
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
|
restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory
|
||||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||||
|
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
|
|||||||
@ -22,8 +22,24 @@ _BLOCKED_NETWORKS = [
|
|||||||
|
|
||||||
_URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
|
_URL_RE = re.compile(r"https?://[^\s\"'`;|<>]+", re.IGNORECASE)
|
||||||
|
|
||||||
|
_allowed_networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
||||||
|
|
||||||
|
|
||||||
|
def configure_ssrf_whitelist(cidrs: list[str]) -> None:
|
||||||
|
"""Allow specific CIDR ranges to bypass SSRF blocking (e.g. Tailscale's 100.64.0.0/10)."""
|
||||||
|
global _allowed_networks
|
||||||
|
nets = []
|
||||||
|
for cidr in cidrs:
|
||||||
|
try:
|
||||||
|
nets.append(ipaddress.ip_network(cidr, strict=False))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
_allowed_networks = nets
|
||||||
|
|
||||||
|
|
||||||
def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
if _allowed_networks and any(addr in net for net in _allowed_networks):
|
||||||
|
return False
|
||||||
return any(addr in net for net in _BLOCKED_NETWORKS)
|
return any(addr in net for net in _BLOCKED_NETWORKS)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.security.network import contains_internal_url, validate_url_target
|
from nanobot.security.network import configure_ssrf_whitelist, contains_internal_url, validate_url_target
|
||||||
|
|
||||||
|
|
||||||
def _fake_resolve(host: str, results: list[str]):
|
def _fake_resolve(host: str, results: list[str]):
|
||||||
@ -99,3 +99,47 @@ def test_allows_normal_curl():
|
|||||||
|
|
||||||
def test_no_urls_returns_false():
|
def test_no_urls_returns_false():
|
||||||
assert not contains_internal_url("echo hello && ls -la")
|
assert not contains_internal_url("echo hello && ls -la")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SSRF whitelist — allow specific CIDR ranges (#2669)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_blocks_cgnat_by_default():
|
||||||
|
"""100.64.0.0/10 (CGNAT / Tailscale) is blocked by default."""
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||||
|
ok, _ = validate_url_target("http://ts.local/api")
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitelist_allows_cgnat():
|
||||||
|
"""Whitelisting 100.64.0.0/10 lets Tailscale addresses through."""
|
||||||
|
configure_ssrf_whitelist(["100.64.0.0/10"])
|
||||||
|
try:
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||||
|
ok, err = validate_url_target("http://ts.local/api")
|
||||||
|
assert ok, f"Whitelisted CGNAT should be allowed, got: {err}"
|
||||||
|
finally:
|
||||||
|
configure_ssrf_whitelist([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitelist_does_not_affect_other_blocked():
|
||||||
|
"""Whitelisting CGNAT must not unblock other private ranges."""
|
||||||
|
configure_ssrf_whitelist(["100.64.0.0/10"])
|
||||||
|
try:
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("evil.com", ["10.0.0.1"])):
|
||||||
|
ok, _ = validate_url_target("http://evil.com/secret")
|
||||||
|
assert not ok
|
||||||
|
finally:
|
||||||
|
configure_ssrf_whitelist([])
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitelist_invalid_cidr_ignored():
|
||||||
|
"""Invalid CIDR entries are silently skipped."""
|
||||||
|
configure_ssrf_whitelist(["not-a-cidr", "100.64.0.0/10"])
|
||||||
|
try:
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
||||||
|
ok, _ = validate_url_target("http://ts.local/api")
|
||||||
|
assert ok
|
||||||
|
finally:
|
||||||
|
configure_ssrf_whitelist([])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user