mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
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:
parent
bb4f7ac5e2
commit
7f7668d975
12
CHANGELOG.md
12
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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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))
|
||||
|
||||
53
backend/tests/test_url_safety.py
Normal file
53
backend/tests/test_url_safety.py
Normal 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"
|
||||
@ -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
|
||||
|
||||
@ -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`。
|
||||
|
||||
### 图像搜索
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user