From 5f08d61d8fb0d88711b9364fc0f904a8876e33fc Mon Sep 17 00:00:00 2001 From: 04cb <0x04cb@gmail.com> Date: Wed, 1 Apr 2026 21:54:35 +0800 Subject: [PATCH] fix(security): add ssrfWhitelist config to unblock Tailscale/CGNAT (#2669) --- nanobot/config/loader.py | 14 ++++++-- nanobot/config/schema.py | 1 + nanobot/security/network.py | 16 +++++++++ tests/security/test_security_network.py | 46 ++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 709564630..c320d2726 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -37,17 +37,27 @@ def load_config(config_path: Path | None = None) -> Config: """ path = config_path or get_config_path() + config = Config() if path.exists(): try: with open(path, encoding="utf-8") as f: data = json.load(f) data = _migrate_config(data) - return Config.model_validate(data) + config = Config.model_validate(data) except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e: logger.warning(f"Failed to load config from {path}: {e}") 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: diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0999bd99e..2c20fb5e3 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -192,6 +192,7 @@ class ToolsConfig(Base): exec: ExecToolConfig = Field(default_factory=ExecToolConfig) restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory 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): diff --git a/nanobot/security/network.py b/nanobot/security/network.py index 900582834..970702b98 100644 --- a/nanobot/security/network.py +++ b/nanobot/security/network.py @@ -22,8 +22,24 @@ _BLOCKED_NETWORKS = [ _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: + 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) diff --git a/tests/security/test_security_network.py b/tests/security/test_security_network.py index 33fbaaaf5..a22c7e223 100644 --- a/tests/security/test_security_network.py +++ b/tests/security/test_security_network.py @@ -7,7 +7,7 @@ from unittest.mock import patch 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]): @@ -99,3 +99,47 @@ def test_allows_normal_curl(): def test_no_urls_returns_false(): 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([])