假设私有部署了一套dify平台,运营同学做了一个”网页摘要”应用,用户贴一个文章链接,工作流自动抓取网页、调用大模型生成摘要。功能上线后,有人在输入框里填了这样一个地址:

1
http://192.168.2.87:8000/internal/user-list

这是公司内网一台服务器上的用户列表接口,只在内网可以访问。很快,这位”用户”通过摘要应用拿到了这份内网用户列表,而他自己从公网根本碰不到这台机器。

今天我们会跟着这个 URL 走完全程,回答四个问题:

  1. SSRF 到底是什么?
  2. 攻击者有哪些绕过校验的常见手法?
  3. 恶意 URL 撞上 dify 的防线时,会在哪一层死掉?
  4. 如何复现这个拦截过程?

一、SSRF 到底是什么

1.1 一个形象的比喻

想象一个跑腿服务:你进不去隔壁那栋有门禁的写字楼,但跑腿小哥有通行卡,楼里畅通无阻。你只需要在 App 里填一个”取件地址”,他就替你去取。

正常情况下你填:”15 层前台,取我的快递。”小哥照办,没问题。

别有用心的人填:”3 层前台,把桌子上文件袋拿给我。”如果小哥不核对这个地址该不该去,你就借用了小哥的门禁权限,拿到了自己根本进不去的地方才有的东西。

SSRF 就是这个故事的数字版:

  • 下单的人 = 攻击者,只能访问应用公开的 API;
  • 跑腿小哥 = 服务器,持有”门禁卡”——能访问内网、云元数据等更多地方;
  • 跑腿服务 = 应用提供的”输入 URL,服务器帮你请求”的功能。
flowchart LR
    Attacker["攻击者
只能访问公开 API"] App["dify API / Worker
持有服务器的网络权限"] subgraph Internal["内网区域(攻击者无法直接进入)"] direction TB Local["本机服务
127.0.0.1"] Intranet["内部服务
192.168.2.87:8000"] Metadata["云元数据服务
169.254.169.254"] end Attacker -->|"① 提交恶意 URL"| App App -->|"② 代替攻击者发请求"| Internal Attacker -.->|"直接访问被拒"| Internal

1.2 SSRF 成立的三个条件

想知道某个功能有没有 SSRF 风险,问这三个问题就够了:

  1. 地址谁说了算? 用户(或者大模型)能不能决定请求发到哪个 URL?
  2. 请求谁发出去? 真正点开这个链接的,是服务器,还是用户自己的浏览器?
  3. 服务器比用户多能去哪? 有没有一些地方,攻击者自己够不着,服务器却够得着?

1.3 分清 SSRF 和 CSRF

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

两者名字很像,容易混淆,但借用的身份完全不同:
最终建立网络连接的是谁? 是服务器,就是 SSRF 的问题;是用户的浏览器,就是 CSRF 的问题。

1.4 dify 中 SSRF 风险

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

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

二、攻击者如何绕过校验

光看原理不过瘾,我们写一个”没有防护的网页摘要服务”,然后亲手打穿它。

2.1 一个 20 行的靶场服务

1
2
3
4
5
6
7
8
9
10
from fastapi import FastAPI
import requests

app = FastAPI()

@app.get("/summarize")
def summarize(url: str):
# 模拟 dify HTTP 节点的核心动作:根据用户给的 URL 发请求
resp = requests.get(url, timeout=5)
return {"content_preview": resp.text[:200]}

在自己的测试环境里启动它(uvicorn main:app --port 8080),再准备一个无认证的”内部服务”——就是故事里的 http://192.168.2.87:8000/internal/user-list,任何无鉴权的小服务都可以扮演这个角色。公网访问不到这个内部服务,但通过 /summarize?url=http://192.168.2.87:8000/internal/user-list,靶场服务器替你访问到了。第一个 SSRF 达成。

2.2 加一道”看起来对”的校验,然后绕过它

大多数开发者第一反应是加字符串校验。我们逐个加上,再逐个打穿:

校验一:禁止内网测试机的 IP

1
2
if "192.168.2.87" in url:
raise ValueError("blocked")

绕过方法——同一个 IP 地址,有很多张”面孔”:

1
2
3
4
http://3232236119/               # 十进制写法,等于 192.168.2.87
http://0xc0a80257/ # 十六进制写法
http://0300.0250.02.0127/ # 八进制写法
http://[::ffff:192.168.2.87]/ # IPv4 映射的 IPv6 地址

字符串视角和 URL 解析器视角不是一回事,黑名单永远列不全。

校验二:只允许可信域名

1
2
if "example.com" not in url:
raise ValueError("blocked")

绕过方法:

1
2
http://example.com@192.168.2.87/      # @ 前面只是"用户名",真实主机是 192.168.2.87
http://192.168.2.87/?q=example.com # 域名出现在参数里,与主机无关

校验三:先解析 DNS,是公网 IP 才放行

1
2
3
4
ip = socket.gethostbyname(host)
if ipaddress.ip_address(ip).is_private:
raise ValueError("blocked")
return requests.get(url)

这个思路已经进步了,但仍有三个洞:

  1. DNS Rebinding:攻击者控制自己的 DNS 服务器,校验时返回公网 IP(TTL 设为 0),requests.get 重新解析时返回内网 IP。校验和连接看到的不是同一个答案。

  2. 重定向跳转:第一个 URL 是公网地址,服务器返回 302 指向 http://192.168.2.87:8000/internal/user-list,HTTP 库自动跟随——后续每一跳都没被校验。

  3. 多解析记录:一个域名同时返回公网 IPv4 和内网 IPv6,只检查其中一个就放行了。

     sequenceDiagram
         participant A as 攻击者DNS
         participant S as 靶场服务器
         participant I as 内网服务
         S->>A: 校验时解析 evil.com
         A-->>S: 返回 1.2.3.4(公网,放行)
         S->>A: 连接时再次解析 evil.com
         A-->>S: 返回 192.168.2.87(内网)
         S->>I: 请求发送到内网服务
     

到这里可以得出结论:围绕”字符串”和”第一次 DNS 解析”的校验都不可靠,校验必须围绕”实际连接到哪个 IP 和端口”展开。

2.3 攻击面不只是 127.0.0.1 和内网

很多系统只检查 10.x.x.x 和 192.168.x.x,这远远不够。完整的”非公网目标”清单至少包括:

  • IPv4 回环:127.0.0.0/8
  • IPv4 私有:10.0.0.0/8、172.16.0.0/12、192.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
  • 容器服务名和内部域名:如 api、redis、db
  • file://、gopher:// 等非预期协议

不要自己维护字符串前缀列表,应使用成熟的 IP 地址库(如 Python ipaddress 的 is_global)判断解析结果,并在网络出口再次执行 ACL。


三、恶意 URL 撞上 dify 的防线时:逐层拦截实录

在 dify 平台中恶意 URL 想访问内网服务,需要连闯三关。

flowchart LR
    Input["恶意 URL
来自用户或模型"] --> Node["第一关:HTTP 节点
统一 HTTP 客户端"] Node -->|"强制走代理"| Squid["第二关:Squid 代理
目标地址 ACL"] Squid --> Check{"目标 IP 是否允许"} Check -->|"私网 / 回环 / Link-local"| Deny["403 拒绝"] Check -->|"允许的公网目标"| Internet["外部服务"] Network["第三关:容器网络
禁止绕过代理直连"] -.->|"兜底"| Node

3.1 第一关:所有出站请求收敛到一个客户端

dify 平台的代码中, 不让业务代码随手创建裸 requests,而是把通用出站请求收敛到 api/core/helper/ssrf_proxy.py 的统一客户端。核心逻辑简化为:

1
2
3
4
5
6
7
8
9
10
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, # 连接池 + 超时限制
)
# HTTP / HTTPS 也可分别配置代理……
# 没有配置代理时会直连 —— 生产部署必须避免这种回退
return httpx.Client(verify=verify, limits=SSRF_CLIENT_LIMITS)

对我们的恶意 URL 来说,这意味着:它没法挑一个”忘了设防”的入口——HTTP 节点、工具调用、插件,全都走同一个客户端、同一个代理。Squid 返回 403 时,客户端还会把它转成明确的 ToolSSRFError,让使用者知道”这是安全策略拦截”,而不是莫名其妙的网络错误。

但要认清这一关的本质:它是一条代码层面的约定,不是自动生效的魔法。这个工厂只是”提供了”一个带代理的客户端,业务代码必须主动从这里拿客户端才受保护——谁要是绕过工厂、自己 import requests 裸写一句 requests.get(url),第一关对他完全不存在(除非环境变量兜底,而环境变量同样可以被 session.trust_env = False 一行关掉)。

所以这一关有两个洞:配置为空时存在直连回退(代码支持代理 ≠ 请求一定经过代理),以及它约束不了”不按约定写代码”的开发者——后来者新增一个功能时自己创建裸客户端,这道防线就被架空了。这正是第三关必须用网络层兜底的原因。

3.2 第二关:Squid 在真实连接点判断目标

为什么应用层校验不够,还要一个代理?回看第二章:字符串校验输给了 URL 的花式写法,DNS 校验输给了 Rebinding。而 Squid 是真正建立连接的那一方——域名最终解析出的目标 IP,会参与它的 dst ACL 判断。

dify 代码中的 docker/ssrf_proxy/squid-common.conf.template 定义了完整的敏感网段:

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

我们的恶意 URL 就死在这里:192.168.2.87 命中 to_private_networks,Squid 直接返回 403,请求根本没有离开代理。第二章里那些绕过手法——十进制 IP、IPv6 映射、重定向——在这一关全部失效,因为判断发生在”即将连接”的时刻,看到的已经是最终的 IP。

3.3 第三关:网络层堵死”绕过代理”的旁路

代理再强,也怕旁路:如果某段代码(用户沙箱代码、第三方插件、未来新增的模块)不读代理环境变量、直接对外发起连接,Squid 就形同虚设。

HTTP 代理环境变量本质是一种”协作式约束”——程序愿意遵守才有效。所以 dify 的 Compose 部署把 Sandbox 这类高风险环境放在受限网络里,唯一出站路径指向 ssrf_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

生产环境还应在容器网络之外再加不可绕过的约束:

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

3.4 业务需要访问内网怎么办:白名单的艺术

企业私有部署 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;
  • 域名白名单用专用内部域名,不要允许过宽的父域;
  • 变更走评审,记录业务负责人和过期时间;
  • 修改后重启代理,并同时测试”应该允许”和”必须拒绝”的目标。

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


四、常见误区

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

五、总结

回到开头的故事。恶意 URL 在 dify 平台里的完整结局是:

  1. 它进入 HTTP 节点,被交给统一的 SSRF 客户端——没有旁路入口可用;
  2. 它到达 Squid 代理,目标 IP 192.168.2.87 命中 ACL——403 拒绝;
  3. 就算某段代码忘了走代理,容器网络也不允许它直连——兜底拦截;
flowchart LR
    Untrusted["不可信 URL"] --> Reduce["缩小可控范围"]
    Reduce --> Validate["规范化与应用校验"]
    Validate --> Proxy["强制出站代理"]
    Proxy --> Network["网络层禁止旁路"]

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

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