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 <willem.jiang@gmail.com>
This commit is contained in:
Hyeonsang Cho 2026-10-03 11:29:56 +09:00 committed by GitHub
parent bb4f7ac5e2
commit 7f7668d975
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 95 additions and 8 deletions

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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(

View File

@ -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()

View File

@ -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))

View File

@ -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"

View File

@ -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

View File

@ -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`。
### 图像搜索