From 9ef5b1e145e80fe75d7bfaec3306649b243c14b2 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sat, 4 Apr 2026 11:35:09 +0000 Subject: [PATCH] fix: reset ssrf whitelist on config reload and document config refresh --- README.md | 15 +++++++++++++ nanobot/config/loader.py | 5 ++--- tests/config/test_config_migration.py | 32 +++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b28e5d6e7..62561827b 100644 --- a/README.md +++ b/README.md @@ -856,6 +856,11 @@ Simply send the command above to your nanobot (via CLI or any chat channel), and Config file: `~/.nanobot/config.json` +> [!NOTE] +> If your config file is older than the current schema, you can refresh it without overwriting your existing values: +> run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. +> nanobot will merge in missing default fields and keep your current settings. + ### Providers > [!TIP] @@ -1235,6 +1240,16 @@ By default, web tools are enabled and web search uses `duckduckgo`, so search wo If you want to disable all built-in web tools entirely, set `tools.web.enable` to `false`. This removes both `web_search` and `web_fetch` from the tool list sent to the LLM. +If you need to allow trusted private ranges such as Tailscale / CGNAT addresses, you can explicitly exempt them from SSRF blocking with `tools.ssrfWhitelist`: + +```json +{ + "tools": { + "ssrfWhitelist": ["100.64.0.0/10"] + } +} +``` + | Provider | Config fields | Env var fallback | Free | |----------|--------------|------------------|------| | `brave` | `apiKey` | `BRAVE_API_KEY` | No | diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index c320d2726..f5b2f33b8 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -54,10 +54,9 @@ def load_config(config_path: Path | None = None) -> 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 + from nanobot.security.network import configure_ssrf_whitelist - configure_ssrf_whitelist(config.tools.ssrf_whitelist) + configure_ssrf_whitelist(config.tools.ssrf_whitelist) def save_config(config: Config, config_path: Path | None = None) -> None: diff --git a/tests/config/test_config_migration.py b/tests/config/test_config_migration.py index c1c951056..add602c51 100644 --- a/tests/config/test_config_migration.py +++ b/tests/config/test_config_migration.py @@ -1,6 +1,18 @@ import json +import socket +from unittest.mock import patch from nanobot.config.loader import load_config, save_config +from nanobot.security.network import validate_url_target + + +def _fake_resolve(host: str, results: list[str]): + """Return a getaddrinfo mock that maps the given host to fake IP results.""" + def _resolver(hostname, port, family=0, type_=0): + if hostname == host: + return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0)) for ip in results] + raise socket.gaierror(f"cannot resolve {hostname}") + return _resolver def test_load_config_keeps_max_tokens_and_ignores_legacy_memory_window(tmp_path) -> None: @@ -126,3 +138,23 @@ def test_onboard_refresh_backfills_missing_channel_fields(tmp_path, monkeypatch) assert result.exit_code == 0 saved = json.loads(config_path.read_text(encoding="utf-8")) assert saved["channels"]["qq"]["msgFormat"] == "plain" + + +def test_load_config_resets_ssrf_whitelist_when_next_config_is_empty(tmp_path) -> None: + whitelisted = tmp_path / "whitelisted.json" + whitelisted.write_text( + json.dumps({"tools": {"ssrfWhitelist": ["100.64.0.0/10"]}}), + encoding="utf-8", + ) + defaulted = tmp_path / "defaulted.json" + defaulted.write_text(json.dumps({}), encoding="utf-8") + + load_config(whitelisted) + 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, err + + load_config(defaulted) + 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