在传统 Web 系统中,用户通常只能访问应用公开的 API;在 AI 应用平台中,工作流 HTTP 节点、插件、工具调用、远程文件导入和代码沙箱却需要主动访问外部网络。

这类能力非常实用,但也意味着:一段用户可控的数据,可能最终变成由服务器发起的网络请求。

如果系统没有限制请求目的地,攻击者就可能借助服务器的网络身份访问原本无法从公网触达的内部服务。这就是 SSRF(Server-Side Request Forgery,服务端请求伪造)。

本文以 Dify 为观察样本,重点回答四个问题:

  1. SSRF 为什么不只是“URL 校验不严格”?
  2. AI 工作流平台为什么天然具有较大的 SSRF 攻击面?
  3. Dify 如何通过统一 HTTP 客户端、Squid 代理和容器网络实现防护?
  4. 我们在自己的系统中应该如何设计一套可落地的出站访问控制体系?

本文讨论防御原理和安全测试方法。测试 SSRF 时只能使用自己拥有或明确获授权的环境,不要探测未授权的网络和服务。


一、从业务能力到安全问题

1.1 一个正常功能如何变成 SSRF

假设我们在 Dify 中设计一个“网页摘要”工作流:

  1. 用户输入一篇文章的 URL;
  2. HTTP 请求节点下载网页;
  3. LLM 节点提取正文并生成摘要;
  4. 工作流返回摘要结果。

正常输入可能是:

1
https://news.example.com/articles/1001

如果 HTTP 节点不限制目的地址,攻击者就可以把 URL 换成服务器所在网络中的地址,例如:

1
2
3
http://127.0.0.1:5001/
http://10.0.0.20:8080/admin
http://169.254.169.254/

浏览器无法直接访问这些地址,不代表 Dify 的 API 或 Worker 不能访问。对于目标服务来说,请求来自受信任的服务器或容器网络,而不是外部攻击者。

flowchart LR
    Attacker["攻击者
只能访问公开 API"] -->|"提交恶意 URL"| App["Dify API / Worker
可访问更多网络"] App -->|"代替攻击者发请求"| Local["本机服务
127.0.0.1"] App -->|"横向访问"| Intranet["内部服务
10.0.0.0/8"] App -->|"读取凭证"| Metadata["云元数据服务
Link-local"]

问题的本质不是服务器“请求了一个 URL”,而是发生了权限借用

  • 借用服务器的网络可达性,绕过公网边界;
  • 借用服务器的来源 IP,绕过基于 IP 的信任;
  • 借用服务器自动携带的凭证、Cookie 或请求头;
  • 借用服务器所在的云环境,访问实例元数据;
  • 借用服务器的带宽和协议能力,扫描或攻击其他目标。

1.2 SSRF 与 CSRF 有什么区别

两者名字相似,但信任边界不同:

对比项 SSRF CSRF
全称 Server-Side Request Forgery Cross-Site Request Forgery
发起请求的一方 服务器 已登录用户的浏览器
被借用的身份 服务器的网络位置和凭证 用户的登录态和 Cookie
常见入口 URL 抓取、Webhook、代理、文件导入 表单、图片、跨站跳转
主要目标 内部服务、云元数据、受限网络 修改用户数据、执行敏感操作

判断一个问题是不是 SSRF,可以问一句:最终建立网络连接的是谁? 如果是服务器根据不可信输入决定目标地址,就应该进入 SSRF 威胁建模。

1.3 为什么 AI 工作流平台更需要关注 SSRF

普通业务系统可能只有一两个固定的外部接口,而 AI 应用平台需要把“连接外部世界”作为通用能力开放给用户:

能力 不可信输入如何进入请求
HTTP 请求节点 URL、请求头、查询参数和请求体可引用工作流变量
Agent 工具调用 模型根据上下文选择工具并生成参数
自定义工具 / OpenAPI 工具定义或运行参数决定目标服务
远程文件导入 用户提交 URL,服务端下载文档、图片或音频
Webhook 调试 用户配置回调地址,平台主动发送测试请求
模型供应商接入 管理员配置 Base URL,服务端调用兼容 API
代码沙箱联网 用户代码主动访问网络资源

这里还有一个 AI 特有问题:请求参数不一定由用户直接输入,也可能由模型间接生成。 Prompt Injection 可以诱导 Agent 调用工具,但真正的安全边界不能依赖模型“是否听话”。无论 URL 来自用户、知识库、模型输出还是第三方工具,都应在实际发起请求的位置执行同一套网络策略。


二、SSRF 的核心原理

2.1 SSRF 成立的三个条件

一次 SSRF 通常同时具备三个条件:

  1. 目标可控:攻击者可以控制 URL 的全部或部分内容;
  2. 服务端发起:应用在服务器侧解析地址并建立连接;
  3. 权限差异:服务器能访问攻击者不能直接访问的资源,或请求会携带额外信任。

可以把风险粗略表示为:

1
SSRF 风险 ≈ 输入可控程度 × 服务端网络权限 × 响应可观察程度

即使响应不可见,也可能形成 Blind SSRF(盲 SSRF):攻击者通过响应时间、DNS 查询、外部回连或业务副作用判断请求是否成功。

2.2 攻击面不只是私有 IPv4

很多系统只检查 10.x.x.x192.168.x.x,这远远不够。应关注的非公网目标至少包括:

  • IPv4 回环地址:127.0.0.0/8
  • IPv4 私有地址:10.0.0.0/8172.16.0.0/12192.168.0.0/16
  • 链路本地地址:169.254.0.0/16
  • 运营商级 NAT 地址:100.64.0.0/10
  • IPv6 回环地址:::1/128
  • IPv6 ULA:fc00::/7
  • IPv6 链路本地地址:fe80::/10
  • IPv4 映射 IPv6 地址:例如 ::ffff:127.0.0.1
  • 容器服务名和内部 DNS 域名:例如 apiredisdb
  • Unix Socket、file://gopher:// 等非预期协议

防护逻辑不应维护几个字符串前缀,而应使用成熟的 IP 地址库判断解析结果是否属于全局可路由地址,并在网络出口再次执行 ACL。

2.3 为什么简单字符串校验很容易失效

下面这些看似合理的代码都存在问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 问题 1:只检查字符串前缀
if url.startswith("http://127.0.0.1"):
raise ValueError("blocked")

# 问题 2:只要包含可信域名就放行
if "example.com" not in url:
raise ValueError("blocked")

# 问题 3:解析一次 DNS,通过后让 HTTP 客户端重新解析
ip = socket.gethostbyname(host)
if ipaddress.ip_address(ip).is_private:
raise ValueError("blocked")
return requests.get(url)

原因包括:

  • URL 中可能包含用户名、端口、转义字符和不同编码,字符串视角与 URL 解析器视角不一致;
  • 域名可能在第一次解析时返回公网 IP,连接时返回内网 IP,形成 DNS Rebinding;
  • 第一个 URL 可能安全,但重定向后的 URL 指向内网;
  • 一个域名可能同时解析出多个 IPv4/IPv6 地址,只检查其中一个不够;
  • 不同 HTTP 库对畸形 URL、反斜杠和数字 IP 的解释可能不同。

因此,校验必须围绕“实际连接到哪个 IP 和端口”展开,而不能只围绕原始字符串。

2.4 SSRF 的影响

SSRF 的危害取决于服务器所在环境:

  1. 内部服务探测:根据状态码、响应时间和错误信息判断端口是否开放;
  2. 访问未鉴权管理接口:许多内部 API 依赖网络隔离,没有额外认证;
  3. 读取云元数据:在配置不当的环境中可能获得临时身份凭证;
  4. 横向移动:调用 Redis、Elasticsearch、数据库代理或内部控制面;
  5. 数据外传:将应用能够读取的数据发送到外部地址;
  6. 资源消耗:下载超大文件、慢响应或无限流,耗尽连接池、内存和 Worker;
  7. 隐藏来源:把平台变成访问第三方服务的代理。

这说明 SSRF 防护不仅要回答“能不能访问”,还要限制协议、端口、请求时长、响应大小、重试次数和并发量。


三、从 Dify 看 SSRF 防护架构

3.1 Dify 的出站请求链路

Dify 不把 SSRF 防护只放在 HTTP 节点的表单校验中,而是把通用出站请求收敛到统一客户端,再通过独立 Squid 代理执行目标网络 ACL。

flowchart LR
    Input["用户输入 / 模型输出 / 工具参数"] --> Node["HTTP 节点 / 工具 / 插件"]
    Node --> Client["core.helper.ssrf_proxy
统一 HTTP 客户端"] Client -->|"HTTP / HTTPS Proxy"| Squid["ssrf_proxy
Squid ACL"] Squid --> Check{"目标地址是否允许"} Check -->|"私网 / 回环 / Link-local"| Deny["403 拒绝"] Check -->|"允许的公网目标"| Internet["外部服务"] Check -->|"显式私网白名单"| Internal["指定内部服务"]

这条链路体现了三个重要原则:

  • 统一入口:业务代码不应该随意创建裸 requestshttpx 客户端;
  • 策略下沉:由最接近真实网络连接的代理判断目的 IP,而不是只相信应用层字符串校验;
  • 默认拒绝敏感网络:确有内部 API 调用需求时,通过最小范围白名单显式开放。

3.2 应用层:统一的 SSRF HTTP 客户端

Dify 当前源码中的 api/core/helper/ssrf_proxy.py 封装了通用出站 HTTP 请求。核心逻辑可以简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def build_ssrf_client(verify: bool) -> httpx.Client:
if config.SSRF_PROXY_ALL_URL:
return httpx.Client(
proxy=config.SSRF_PROXY_ALL_URL,
verify=verify,
limits=SSRF_CLIENT_LIMITS,
)

if config.SSRF_PROXY_HTTP_URL and config.SSRF_PROXY_HTTPS_URL:
return httpx.Client(
mounts={
"http://": httpx.HTTPTransport(
proxy=config.SSRF_PROXY_HTTP_URL,
verify=verify,
),
"https://": httpx.HTTPTransport(
proxy=config.SSRF_PROXY_HTTPS_URL,
verify=verify,
),
},
limits=SSRF_CLIENT_LIMITS,
)

# 没有配置代理时会直接访问目标地址
return httpx.Client(verify=verify, limits=SSRF_CLIENT_LIMITS)

几个关键点:

  1. HTTP 和 HTTPS 可以分别配置代理,也可以通过 SSRF_PROXY_ALL_URL 使用统一代理;
  2. 客户端复用连接池,避免每次请求重新创建连接;
  3. 默认配置连接、读取、写入和总超时;
  4. 可以限制响应体大小,避免大响应导致内存消耗;
  5. Squid 返回 401/403 且响应头能识别出 Squid 时,转换为明确的 ToolSSRFError
  6. 如果代理环境变量为空,代码存在直连回退,因此生产部署必须同时保证配置和网络出口策略正确。

最后一点尤其重要:代码支持代理,不等于所有请求一定经过代理。 生产环境应在容器网络、防火墙或 Kubernetes NetworkPolicy 层阻止 API、Worker、Sandbox 绕过代理直连外部或内部敏感网络。

3.3 网络层:Squid 如何阻止敏感目的地址

Dify 的 docker/ssrf_proxy/squid-common.conf.template 定义了 to_private_networks 目标 ACL,覆盖典型的 IPv4 和 IPv6 敏感网段,例如:

1
2
3
4
5
6
7
8
9
10
11
acl to_private_networks dst 0.0.0.0/8
acl to_private_networks dst 10.0.0.0/8
acl to_private_networks dst 100.64.0.0/10
acl to_private_networks dst 127.0.0.0/8
acl to_private_networks dst 169.254.0.0/16
acl to_private_networks dst 172.16.0.0/12
acl to_private_networks dst 192.168.0.0/16
acl to_private_networks dst ::1/128
acl to_private_networks dst ::ffff:0:0/96
acl to_private_networks dst fc00::/7
acl to_private_networks dst fe80::/10

主配置按顺序应用规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports

# 加载管理员明确配置的私网例外
include /etc/squid/dify_allow_private.conf

# 默认拒绝其他私网目标
http_access deny to_private_networks

# 放行满足其余策略的客户端请求
http_access allow client_localnet
http_access allow localhost
http_access deny all

与应用层自己解析 DNS 相比,代理更接近真实连接点:域名最终解析出的目标 IP 会参与 dst ACL 判断,可以统一覆盖 HTTP 节点、工具和沙箱等多个调用方。

不过代理也不是银弹:如果业务容器仍能直接访问目标网络,某段遗漏了代理配置的代码仍可能绕过 Squid。因此真正可靠的设计是:

1
应用统一客户端 + 强制代理环境 + 网络层禁止旁路 + 目标服务自身鉴权

3.4 容器网络:为什么要限制“绕过代理”

Dify 的 Compose 部署中,ssrf_proxy 同时连接代理专用网络和默认网络。Sandbox 配置 SANDBOX_HTTP_PROXYSANDBOX_HTTPS_PROXY 后,通过代理访问网络。

理想的网络关系如下:

flowchart TB
    subgraph Restricted["受限网络"]
        Sandbox["Sandbox / 高风险执行环境"]
    end

    subgraph ProxyZone["代理边界"]
        Proxy["SSRF Proxy"]
    end

    subgraph ServiceNet["业务网络"]
        API["Dify API"]
        DB[("PostgreSQL")]
        Redis[("Redis")]
    end

    Sandbox -->|"唯一出站路径"| Proxy
    Proxy -->|"策略允许"| Public["公网服务"]
    Proxy -.->|"默认拒绝"| DB
    Proxy -.->|"默认拒绝"| Redis
    API --> DB
    API --> Redis

需要注意,HTTP 代理环境变量是一种“协作式约束”:程序愿意读取它才会走代理。对用户代码、第三方插件或未来新增模块,最好再加一层不可绕过的网络约束,例如:

  • Docker internal 网络与精确的网络连接关系;
  • 主机防火墙限制容器网段的 FORWARD/OUTPUT;
  • Kubernetes NetworkPolicy 默认拒绝 Egress,只允许访问代理 Pod;
  • 云安全组或服务网格 Egress Gateway;
  • 数据库、Redis 和内部管理面继续启用认证,不把网络隔离当作唯一防线。

3.5 私网白名单:解决安全与业务的冲突

企业自建 Dify 时,HTTP 节点经常需要访问内部模型网关或业务 API。如果一刀切禁止所有私网地址,工作流就无法落地;如果直接关闭 SSRF 代理,又会重新暴露整个内网。

Dify 提供两个显式例外配置:

1
2
3
4
5
# 允许指定私网 IP 或 CIDR,多个值用逗号分隔
SSRF_PROXY_ALLOW_PRIVATE_IPS=10.20.30.40/32,172.20.5.0/24

# 允许解析到受限地址的指定内部域名后缀
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS=.model-gateway.example.internal

配置时遵守最小权限原则:

  • 优先允许单个 /32 地址,不要图省事放开整个 10.0.0.0/8
  • 域名白名单使用专用内部域名,不要允许过宽的父域;
  • 在目标服务前增加认证、限流和审计;
  • 区分开发、测试、生产环境,不要复制同一份宽泛白名单;
  • 变更白名单需要走评审,并记录业务负责人和过期时间;
  • 修改后重启代理,并同时测试“应该允许”和“必须拒绝”的目标。

白名单不是“关闭安全”,而是把隐式网络权限变成可审计的显式策略。

3.6 不同请求场景需要不同封装

Dify 的 ssrf_proxy.py 注释中特别指出:通用外部 HTTP 交互使用 SSRF 客户端;远程文件下载、探测和元数据检查应走 core.file.remote_fetcher

原因是“发 API 请求”和“下载文件”虽然都使用 HTTP,但资源风险不同:

场景 额外控制
通用 API 请求 方法、头部、超时、重试、TLS 校验
远程文件下载 文件大小、Content-Type、文件名、存储落点、压缩炸弹
Webhook 目标白名单、签名、重放保护、敏感头部清理
OAuth/OIDC Discovery Issuer 绑定、元数据字段校验、重定向限制
模型 Base URL 管理员权限、租户隔离、密钥不跨域转发

“所有请求都经过代理”只是共同底线,业务场景仍需要自己的上层约束。


四、构建一套纵深防御方案

4.1 第一层:减少可控范围

最安全的 URL 是不让用户提供完整 URL。

例如,业务只需要查询订单状态,可以让用户提供 order_id

1
2
# 推荐:目标域名和路径结构由服务端控制
url = f"https://order-api.example.com/v1/orders/{quote(order_id)}"

而不是让用户提供:

1
2
# 风险更高:用户控制协议、主机、端口和路径
url = user_input["callback_url"]

按安全性从高到低排序:

  1. 固定目标,只接受业务参数;
  2. 从管理员维护的目标列表中选择;
  3. 只允许精确域名白名单;
  4. 允许任意公网 HTTP/HTTPS 目标;
  5. 允许任意协议、任意网络目标。

4.2 第二层:规范化解析与应用校验

必须接受完整 URL 时,应用层至少完成:

  1. 使用统一、成熟的 URL 解析器,只解析一次;
  2. 只允许业务需要的协议,通常是 httphttps
  3. 禁止 URL 中携带用户名和密码;
  4. 对主机名做 IDNA 规范化,对 IP 使用标准 IP 库解析;
  5. 限制端口集合;
  6. 解析全部 A/AAAA 记录并检查每个地址;
  7. 禁止回环、私网、链路本地、多播、保留地址和未指定地址;
  8. 不自动跟随重定向,或对每一跳重新执行完整校验;
  9. 连接实际已校验的地址,避免校验和连接分别解析 DNS;
  10. 清理跨域重定向时的 Authorization、Cookie 等敏感头。

下面是一段用于表达思路的伪代码,不应直接作为完整安全库使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from ipaddress import ip_address
from urllib.parse import urlsplit

ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_PORTS = {80, 443}

def validate_target(raw_url: str) -> Target:
parsed = urlsplit(raw_url)

if parsed.scheme.lower() not in ALLOWED_SCHEMES:
raise UnsafeTarget("unsupported scheme")
if not parsed.hostname or parsed.username or parsed.password:
raise UnsafeTarget("invalid authority")

port = parsed.port or (443 if parsed.scheme == "https" else 80)
if port not in ALLOWED_PORTS:
raise UnsafeTarget("port is not allowed")

addresses = resolve_all(parsed.hostname) # 同时获取 A 和 AAAA
if not addresses:
raise UnsafeTarget("hostname cannot be resolved")

for value in addresses:
addr = ip_address(value)
if not addr.is_global:
raise UnsafeTarget(f"non-public address: {addr}")

return Target(parsed=parsed, resolved_addresses=addresses)

应用校验的价值是快速失败和输出友好错误,但它不能代替出口代理,因为 DNS、重定向和底层连接仍可能出现实现差异。

4.3 第三层:强制出站代理或 Egress Gateway

把所有不可信目标请求收敛到专用出口:

  • 代理负责 DNS 解析和目标网段判断;
  • 代理只开放必要协议和端口;
  • 代理记录请求来源、目标域名、解析 IP、规则命中和响应状态;
  • 业务容器不能绕过代理直连;
  • 代理本身使用最小权限运行,不与数据库共享凭证;
  • 对不同租户或不同风险模块可以使用不同策略组。

这种设计将散落在业务代码中的“安全判断”变成集中、可审计的基础设施策略。

4.4 第四层:控制资源消耗

即使目标是合法公网地址,请求仍可能拖垮系统:

风险 控制措施
慢连接 / 慢响应 分离设置 connect、read、write、total timeout
超大响应 流式读取并限制解压后的字节数
压缩炸弹 限制 Content-Encoding,按解码后大小计数
无限重定向 限制跳数,每跳重新校验
重试风暴 限制次数,指数退避并加入随机抖动
连接耗尽 限制连接池、单租户并发和队列长度
大文件落盘 限制类型、大小、数量和临时目录配额

Dify 的 SSRF 客户端配置了请求超时、重试和连接池限制,HTTP 请求节点还区分文本与二进制响应大小。这说明 SSRF 防护与资源治理应该共同设计。

4.5 第五层:保护目标服务

不能因为部署了 SSRF 代理,就让内部服务保持无认证状态。目标服务还应:

  • 启用强认证和最小权限授权;
  • 不以来源 IP 作为唯一认证依据;
  • 对管理接口使用独立网络和身份;
  • 云环境启用更安全的元数据访问机制,并限制不必要的元数据路由;
  • Redis、数据库、消息队列不对通用业务网络暴露;
  • 对敏感操作增加幂等键、审计和速率限制。

纵深防御的目标是:即使某一层被绕过,攻击者仍不能直接获得高价值权限。


五、在 Dify 中的配置与排障

5.1 关键配置

自托管 Dify 时重点检查这些环境变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# API / Worker 的通用 SSRF 出站代理
SSRF_PROXY_HTTP_URL=http://ssrf_proxy:3128
SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128

# 默认超时
SSRF_DEFAULT_TIME_OUT=5
SSRF_DEFAULT_CONNECT_TIME_OUT=5
SSRF_DEFAULT_READ_TIME_OUT=5
SSRF_DEFAULT_WRITE_TIME_OUT=5

# 谨慎配置的私网例外
SSRF_PROXY_ALLOW_PRIVATE_IPS=
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS=

# Sandbox 联网也经过 SSRF 代理
SANDBOX_ENABLE_NETWORK=true
SANDBOX_HTTP_PROXY=http://ssrf_proxy:3128
SANDBOX_HTTPS_PROXY=http://ssrf_proxy:3128

不同 Dify 版本的 Compose 模板和变量组织可能变化,升级时应以目标版本的 .env.exampledocker-compose-template.yaml 和生成后的 docker-compose.yaml 为准,不要机械复制旧版本配置。

5.2 为什么 HTTP 节点返回 403

HTTP 请求节点出现 403 Forbidden 时,先判断响应来自目标服务还是 Squid:

1
2
docker compose logs --tail=200 ssrf_proxy
docker compose logs --tail=200 api worker

排查顺序:

  1. 查看响应的 ServerVia 是否包含 Squid;
  2. 查看代理日志是否出现 TCP_DENIED/403
  3. 确认目标域名最终解析到公网还是私网地址;
  4. 确认 URL 使用的端口是否属于代理允许端口;
  5. 如果确实是内部业务 API,只放行最小 IP/CIDR 或专用域名;
  6. 重启 ssrf_proxy 后重新执行正向和反向测试;
  7. 如果 403 来自 Nginx 或目标应用,应检查目标端鉴权,不要误改 SSRF 白名单。

不要用“清空 SSRF_PROXY_HTTP_URL”或“让请求绕过代理”解决 403。那只是把可见的业务错误变成不可见的安全缺口。

5.3 一套安全的验证矩阵

在隔离测试环境中,可以建立以下回归用例:

用例 预期结果
访问允许的公网测试站点 成功
访问 127.0.0.1 拒绝
访问 RFC 1918 私网地址 拒绝
访问链路本地地址 拒绝
公网 URL 重定向到私网 拒绝
域名同时返回公网和私网地址 拒绝
IPv4 映射 IPv6 的回环地址 拒绝
非 HTTP/HTTPS 协议 拒绝
响应超过大小限制 中止并记录明确错误
白名单中的单个内部 API 成功
同网段但未加入白名单的地址 拒绝

测试的重点不是积累“绕过 Payload”,而是验证所有请求入口最终都经过同一个不可绕过的策略点。

5.4 日志与告警

建议为 SSRF 代理建立以下指标:

  • 按调用模块、租户统计的请求量和拒绝量;
  • 被拒绝的目标网段、域名和端口;
  • DNS 解析结果与规则命中原因;
  • 连接、读取、写入超时数量;
  • 响应体超限和重定向超限数量;
  • 单个工作流短时间访问大量目标的异常行为;
  • 白名单命中量和长期未使用的白名单项。

告警中不要直接记录完整的 Authorization、Cookie、查询参数或请求体,避免安全日志反而泄露密钥和个人数据。


六、常见误区

误区 真实情况
“禁止 127.0.0.1 就安全了” 还存在私网、IPv6、Link-local、内部域名和多种地址表示
“正则匹配 URL 就够了” URL 解析、DNS 和实际连接之间存在语义差异
“第一次 DNS 解析是公网 IP 就放行” 需要考虑多记录、DNS Rebinding 和校验/连接时序
“第一个 URL 安全就可以自动重定向” 每一跳都必须重新执行同样的策略
“HTTP 节点加校验就够了” 工具、插件、文件下载、模型 Base URL 也会产生出站请求
“设置了代理变量就不能绕过” 某些代码可以忽略代理变量,必须配合网络层禁止直连
“内网服务不需要认证” SSRF、错误路由或容器逃逸都可能跨越网络边界
“关闭代理能解决请求失败” 它只会移除防线,应通过最小白名单解决合法业务需求
“SSRF 只会泄露数据” 还可能修改内部状态、消耗资源或成为横向移动入口

七、总结与开发启示

通过 Dify 可以看到,SSRF 防护不是一个 if is_private_ip 就能解决的输入校验问题,而是一套完整的出站访问控制

  1. 业务层尽量不接收完整 URL,缩小用户可控范围;
  2. 所有通用出站请求收敛到统一的 SSRF HTTP 客户端;
  3. 由 Squid 或 Egress Gateway 在真实连接边界判断目标 IP、端口和域名;
  4. API、Worker、Sandbox 和插件不能绕过代理直连;
  5. 重定向、DNS、IPv6、响应大小、超时和重试都属于防护范围;
  6. 合法内部访问通过最小白名单显式开放,而不是整体关闭防护;
  7. 内部服务继续启用认证、授权、审计和限流;
  8. 用自动化回归和代理日志证明策略持续有效。

对于 AI 应用平台,模型输出永远不能成为安全决策。模型可以决定“想调用哪个工具”,但网络基础设施必须决定“这个请求实际上能到哪里”。

flowchart LR
    Untrusted["不可信 URL"] --> Reduce["缩小可控范围"]
    Reduce --> Validate["规范化与应用校验"]
    Validate --> Proxy["强制出站代理"]
    Proxy --> Network["网络层禁止旁路"]
    Network --> Auth["目标服务认证授权"]
    Auth --> Observe["日志、告警与回归测试"]

真正可靠的安全设计不是假设输入不会作恶,而是让恶意输入即使进入系统,也无法越过明确、可审计、不可绕过的权限边界。