假设私有部署了一套dify平台,运营同学做了一个”网页摘要”应用,用户贴一个文章链接,工作流自动抓取网页、调用大模型生成摘要。功能上线后,有人在输入框里填了这样一个地址:
1 | http://192.168.2.87:8000/internal/user-list |
这是公司内网一台服务器上的用户列表接口,只在内网可以访问。很快,这位”用户”通过摘要应用拿到了这份内网用户列表,而他自己从公网根本碰不到这台机器。
今天我们会跟着这个 URL 走完全程,回答四个问题:
- SSRF 到底是什么?
- 攻击者有哪些绕过校验的常见手法?
- 恶意 URL 撞上 dify 的防线时,会在哪一层死掉?
- 如何复现这个拦截过程?
一、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 风险,问这三个问题就够了:
- 地址谁说了算? 用户(或者大模型)能不能决定请求发到哪个 URL?
- 请求谁发出去? 真正点开这个链接的,是服务器,还是用户自己的浏览器?
- 服务器比用户多能去哪? 有没有一些地方,攻击者自己够不着,服务器却够得着?
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 | from fastapi import FastAPI |
在自己的测试环境里启动它(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 | if "192.168.2.87" in url: |
绕过方法——同一个 IP 地址,有很多张”面孔”:
1 | http://3232236119/ # 十进制写法,等于 192.168.2.87 |
字符串视角和 URL 解析器视角不是一回事,黑名单永远列不全。
校验二:只允许可信域名
1 | if "example.com" not in url: |
绕过方法:
1 | http://example.com@192.168.2.87/ # @ 前面只是"用户名",真实主机是 192.168.2.87 |
校验三:先解析 DNS,是公网 IP 才放行
1 | ip = socket.gethostbyname(host) |
这个思路已经进步了,但仍有三个洞:
DNS Rebinding:攻击者控制自己的 DNS 服务器,校验时返回公网 IP(TTL 设为 0),
requests.get重新解析时返回内网 IP。校验和连接看到的不是同一个答案。重定向跳转:第一个 URL 是公网地址,服务器返回 302 指向
http://192.168.2.87:8000/internal/user-list,HTTP 库自动跟随——后续每一跳都没被校验。多解析记录:一个域名同时返回公网 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 | def build_ssrf_client(verify: bool) -> httpx.Client: |
对我们的恶意 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 | acl to_private_networks dst 0.0.0.0/8 |
主配置按顺序裁决,默认拒绝:
1 | http_access deny !Safe_ports |
我们的恶意 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 | # 允许指定私网 IP 或 CIDR,多个值用逗号分隔 |
使用白名单的纪律:
- 优先放行单个
/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 平台里的完整结局是:
- 它进入 HTTP 节点,被交给统一的 SSRF 客户端——没有旁路入口可用;
- 它到达 Squid 代理,目标 IP
192.168.2.87命中 ACL——403 拒绝; - 就算某段代码忘了走代理,容器网络也不允许它直连——兜底拦截;
flowchart LR
Untrusted["不可信 URL"] --> Reduce["缩小可控范围"]
Reduce --> Validate["规范化与应用校验"]
Validate --> Proxy["强制出站代理"]
Proxy --> Network["网络层禁止旁路"]
对 AI 应用平台,还有一条必须记住:模型输出永远不能成为安全决策。 模型可以决定”想调用哪个工具”,但网络基础设施必须决定”这个请求实际上能到哪里”。
真正可靠的安全设计,不是假设输入不会作恶,而是让恶意输入即使进入系统,也无法越过明确、可审计、不可绕过的权限边界。