From 7f7668d975ec980161d1258d2d56d7587a70c47e Mon Sep 17 00:00:00 2001 From: Hyeonsang Cho Date: Sat, 3 Oct 2026 11:29:56 +0900 Subject: [PATCH] fix(community): refuse non-global addresses in the shared SSRF guard (#6202) * fix(community): refuse non-global addresses in the shared SSRF guard is_blocked_address ORed is_private, is_loopback, is_link_local, is_reserved, is_multicast and is_unspecified. None of them covers the 100.64.0.0/10 shared address space, which holds CGNAT and Tailscale hosts and Alibaba Cloud's 100.100.100.200 instance metadata endpoint, so web_fetch, web_capture, the agentic browser and personal MCP connections could reach it, including through the IPv4-mapped ::ffff:100.100.100.200 form a DNS answer can carry. Also refuse any address that is not is_global, the classification the repo's other SSRF filters already use. Keep the existing flags alongside it: some non-public forms still report is_global, such as the NAT64 spelling of a metadata address (64:ff9b::a9fe:a9fe) and 224.0.0.1, so an is_global-only check would newly allow them. * docs(changelog): reference #6202 in the shared address space entry * test(community): pin the IPv4-mapped metadata spelling at the web_fetch and personal MCP entry points --------- Co-authored-by: Willem Jiang --- CHANGELOG.md | 12 +++++ CHANGELOG_zh.md | 8 +++ backend/docs/CONFIGURATION.md | 3 +- .../harness/deerflow/community/url_safety.py | 11 +++- backend/tests/test_crawl4ai_tools.py | 8 ++- backend/tests/test_personal_mcp_network.py | 2 +- backend/tests/test_url_safety.py | 53 +++++++++++++++++++ frontend/src/content/en/harness/tools.mdx | 3 +- frontend/src/content/zh/harness/tools.mdx | 3 +- 9 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_url_safety.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d07b310ec8..ce3e137142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -460,6 +460,17 @@ This release closes that milestone with **301 merged pull requests**. ### Fixed +- **community:** The shared SSRF guard now refuses every non-global address, + including the `100.64.0.0/10` shared address space that its flag checks let + through. That range holds CGNAT and Tailscale hosts and Alibaba Cloud's + `100.100.100.200` instance metadata endpoint, so `web_fetch` (crawl4ai, + Browserless, fastcrw), `web_capture`, the agentic browser, and personal MCP + connections could reach them, including through the IPv4-mapped + `::ffff:100.100.100.200` form a DNS answer can carry. The existing flag + checks stay, because some non-public forms such as the NAT64 spelling of a + metadata address still report as global. Operators who intentionally fetch + tailnet or CGNAT hosts with these tools must now set + `allow_private_addresses: true`. ([#6202]) - **browser:** The agentic browser can no longer be steered to a private or cloud-metadata host by a DNS answer that changes after the SSRF check. The navigate screen and the per-request guard resolve a hostname to vet it, but @@ -7598,4 +7609,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag. [#6138]: https://github.com/bytedance/deer-flow/pull/6138 [#6140]: https://github.com/bytedance/deer-flow/pull/6140 [#6201]: https://github.com/bytedance/deer-flow/pull/6201 +[#6202]: https://github.com/bytedance/deer-flow/pull/6202 diff --git a/CHANGELOG_zh.md b/CHANGELOG_zh.md index 3e3dfb735e..c5171938b3 100644 --- a/CHANGELOG_zh.md +++ b/CHANGELOG_zh.md @@ -419,6 +419,13 @@ ### 修复 +- **社区工具:** 共享 SSRF 校验现在拒绝所有非全局地址,包括原先的标志位检查放行的 + `100.64.0.0/10` 共享地址段。该地址段包含 CGNAT 与 Tailscale 主机以及阿里云 + `100.100.100.200` 实例元数据端点,因此 `web_fetch`(crawl4ai、Browserless、 + fastcrw)、`web_capture`、智能浏览器和个人 MCP 连接此前都能访问它们,包括 DNS + 应答可以携带的 IPv4 映射形式 `::ffff:100.100.100.200`。原有的标志位检查仍然保留, + 因为部分非公网形式(例如元数据地址的 NAT64 写法)依然被判定为全局地址。有意通过这些 + 工具访问 tailnet 或 CGNAT 主机的运维人员现在需要设置 `allow_private_addresses: true`。([#6202]) - **浏览器:** 智能浏览器不会再因为 SSRF 检查之后发生变化的 DNS 应答而被引向 内网或云元数据主机。导航检查和逐请求守卫会解析主机名进行筛查,但 Chromium 建立连接时会再次解析,因此重绑定 DNS 服务器可以对检查返回公网地址、对连接返回 @@ -6354,3 +6361,4 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子 [#6138]: https://github.com/bytedance/deer-flow/pull/6138 [#6140]: https://github.com/bytedance/deer-flow/pull/6140 [#6201]: https://github.com/bytedance/deer-flow/pull/6201 +[#6202]: https://github.com/bytedance/deer-flow/pull/6202 diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 34e97932c6..7e3fb3aa9f 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -647,7 +647,8 @@ tools: `web_capture` writes screenshots to the current thread's `/mnt/user-data/outputs` directory and presents the image path through the standard artifact mechanism. By -default it refuses URLs that resolve to private, loopback, link-local, or +default it refuses URLs that resolve to private, loopback, link-local, +shared (`100.64.0.0/10`, used by CGNAT and Tailscale), other non-global, or cloud-metadata addresses; set `allow_private_addresses: true` only when you intentionally point the tool at an internal target. diff --git a/backend/packages/harness/deerflow/community/url_safety.py b/backend/packages/harness/deerflow/community/url_safety.py index 69c0bd921d..c6e1b36d72 100644 --- a/backend/packages/harness/deerflow/community/url_safety.py +++ b/backend/packages/harness/deerflow/community/url_safety.py @@ -31,8 +31,15 @@ def resolve_host_addresses(hostname: str) -> list[ipaddress._BaseAddress]: def is_blocked_address(address: ipaddress._BaseAddress) -> bool: - """Return True for addresses web tools should not reach by default.""" - return address.is_private or address.is_loopback or address.is_link_local or address.is_reserved or address.is_multicast or address.is_unspecified + """Return True for addresses web tools should not reach by default. + + ``not is_global`` catches special-purpose ranges the individual flags miss, + notably the 100.64.0.0/10 shared address space (CGNAT, Tailscale, and + Alibaba Cloud's ``100.100.100.200`` instance metadata endpoint). The flags + stay because some non-public forms still report ``is_global``, such as the + NAT64 spelling of a metadata address (``64:ff9b::a9fe:a9fe``). + """ + return not address.is_global or address.is_private or address.is_loopback or address.is_link_local or address.is_reserved or address.is_multicast or address.is_unspecified def resolve_public_addresses( diff --git a/backend/tests/test_crawl4ai_tools.py b/backend/tests/test_crawl4ai_tools.py index 4f97ea8ff8..615d89e43a 100644 --- a/backend/tests/test_crawl4ai_tools.py +++ b/backend/tests/test_crawl4ai_tools.py @@ -232,12 +232,16 @@ class TestCrawl4AiTools: assert result.startswith("Error:") mock_client.fetch_markdown.assert_called_once() + @pytest.mark.parametrize( + "metadata_url", + ["http://169.254.169.254/latest/meta-data/", "http://100.100.100.200/latest/meta-data/", "http://[::ffff:100.100.100.200]/latest/meta-data/"], + ) @patch("deerflow.community.crawl4ai.tools._build_client") - async def test_web_fetch_tool_rejects_metadata_ip(self, mock_build): + async def test_web_fetch_tool_rejects_metadata_ip(self, mock_build, metadata_url): from deerflow.community.crawl4ai import tools with patch("deerflow.community.crawl4ai.tools._get_tool_config", return_value=None): - result = await tools.web_fetch_tool.ainvoke("http://169.254.169.254/latest/meta-data/") + result = await tools.web_fetch_tool.ainvoke(metadata_url) assert "private, loopback, or metadata" in result mock_build.assert_not_called() diff --git a/backend/tests/test_personal_mcp_network.py b/backend/tests/test_personal_mcp_network.py index dc83ba8a05..f440a34223 100644 --- a/backend/tests/test_personal_mcp_network.py +++ b/backend/tests/test_personal_mcp_network.py @@ -93,7 +93,7 @@ async def test_personal_transport_does_not_share_tls_across_hostnames(monkeypatc @pytest.mark.asyncio -@pytest.mark.parametrize("address", ["127.0.0.1", "10.0.0.1", "169.254.169.254", "::1"]) +@pytest.mark.parametrize("address", ["127.0.0.1", "10.0.0.1", "169.254.169.254", "100.100.100.200", "::ffff:100.100.100.200", "::1"]) async def test_personal_transport_blocks_private_addresses_before_connect(monkeypatch, address): real_resolve = socket.getaddrinfo monkeypatch.setattr(socket, "getaddrinfo", lambda host, port, *args, **kwargs: real_resolve(address, port, *args, **kwargs)) diff --git a/backend/tests/test_url_safety.py b/backend/tests/test_url_safety.py new file mode 100644 index 0000000000..c7bad100c9 --- /dev/null +++ b/backend/tests/test_url_safety.py @@ -0,0 +1,53 @@ +"""Address classification shared by every SSRF-screened web tool.""" + +import ipaddress + +import pytest + +from deerflow.community.url_safety import is_blocked_address, validate_public_http_url + + +@pytest.mark.parametrize( + "address", + [ + "100.64.0.0", + "100.64.1.1", # CGNAT / Tailscale node + "100.100.100.200", # Alibaba Cloud ECS instance metadata + "100.127.255.255", + "::ffff:100.100.100.200", # IPv4-mapped spelling of the same endpoint + ], +) +def test_shared_address_space_is_blocked(address): + assert is_blocked_address(ipaddress.ip_address(address)) + + +@pytest.mark.parametrize( + "address", + [ + "127.0.0.1", + "10.0.0.5", + "169.254.169.254", + "0.0.0.0", + "224.0.0.1", + "::1", + "fe80::1", + "fc00::1", + "::ffff:127.0.0.1", + # NAT64 spelling of a metadata address reports is_global, so the + # reserved/private flags must keep applying alongside it. + "64:ff9b::a9fe:a9fe", + ], +) +def test_previously_blocked_addresses_stay_blocked(address): + assert is_blocked_address(ipaddress.ip_address(address)) + + +@pytest.mark.parametrize("address", ["8.8.8.8", "93.184.215.14", "100.63.255.255", "100.128.0.0", "2606:4700:4700::1111"]) +def test_public_addresses_stay_reachable(address): + assert not is_blocked_address(ipaddress.ip_address(address)) + + +def test_validator_refuses_a_hostname_resolving_into_shared_address_space(): + error = validate_public_http_url("http://metadata.example/latest/meta-data/", resolver=lambda _host: [ipaddress.ip_address("100.100.100.200")]) + + assert error == "Error: Refusing to fetch a private, loopback, or metadata address" diff --git a/frontend/src/content/en/harness/tools.mdx b/frontend/src/content/en/harness/tools.mdx index 8e7506af8b..4435b819a4 100644 --- a/frontend/src/content/en/harness/tools.mdx +++ b/frontend/src/content/en/harness/tools.mdx @@ -297,7 +297,8 @@ tools: Captures rendered web pages with Browserless `/screenshot` and presents the image through DeerFlow's artifact system. This is useful for visual evidence, UI checks, and JavaScript-heavy pages where Markdown extraction is not enough. By default it -refuses URLs that resolve to private, loopback, or cloud-metadata addresses; +refuses URLs that resolve to private, loopback, shared (`100.64.0.0/10`, used by +CGNAT and Tailscale), other non-global, or cloud-metadata addresses; set `allow_private_addresses: true` only when you intentionally target an internal host. ### Image search diff --git a/frontend/src/content/zh/harness/tools.mdx b/frontend/src/content/zh/harness/tools.mdx index ea22fe1bfb..a7b4ebd38a 100644 --- a/frontend/src/content/zh/harness/tools.mdx +++ b/frontend/src/content/zh/harness/tools.mdx @@ -289,7 +289,8 @@ tools: 使用 Browserless `/screenshot` 渲染网页并截图,然后通过 DeerFlow 的 artifact 机制把图片呈现给用户。适合网页视觉证据、UI 检查,以及仅提取 Markdown 不够的 -JavaScript 页面。默认会拒绝解析到私网、回环或云元数据地址的 URL;仅在确实需要 +JavaScript 页面。默认会拒绝解析到私网、回环、共享地址(`100.64.0.0/10`,CGNAT 与 +Tailscale 使用)、其他非全局或云元数据地址的 URL;仅在确实需要 截取内网主机时才设置 `allow_private_addresses: true`。 ### 图像搜索