公开诊断 API 的 SSRF 防护:为什么不能只校验 URL
公开 HTTP 和网站诊断 API 会主动访问用户提交的目标,因此必须在协议、跳转、DNS 解析和 IP 范围上建立清晰边界。
这篇文章按完整正文来整理,从背景、实现到用法都可以连起来读。
公开诊断 API 会访问不可信目标
OpsKitPro 提供 HTTP 和网站诊断 API,是因为网页工具如果能被脚本、CI 检查、告警机器人和 AI Agent 直接调用,价值会更高。
但这也意味着 API 会接收用户提交的目标,并从服务端主动发起请求。
这正是 SSRF 风险出现的形状。
网站诊断 endpoint 不是一个静态 JSON endpoint。它可能会解析 DNS、连接目标主机、跟随跳转、检查响应头,并在多个跳转节点上重复这些动作。如果目标没有被严格约束,诊断服务器就可能被诱导去访问调用者本来不应该通过公开 API 触达的位置。
所以第一条规则很简单:
公开诊断 API 必须把每一个目标都当成不可信输入,直到它完成解析、分类和校验。
URL 字符串校验只是第一道门
很多 SSRF 防护容易停在“校验 URL 格式”这一步。
解析 URL 当然重要。API 应该拒绝格式错误的目标、不支持的 scheme、URL 里的账号密码、对服务端请求没有意义的 fragment,以及其他容易产生歧义的输入。
但只做字符串层面的校验远远不够。
下面这些情况,表面看起来都可能很正常:
- 一个公网域名解析到了私网地址
- 一个公网 URL 通过
302跳转到了localhost - 一个域名在不同解析时刻返回了不同 IP
- 一个 IPv6 literal 指向了保留地址段
- 一个非常规端口对应了内部服务
- 一条跳转链里,只有最后一跳是危险目标
真正危险的不只是用户输入了什么,而是服务端在 DNS 解析和跳转之后实际连接到了哪里。
限制协议和端口
对 OpsKitPro 的公开诊断 API 来说,有意义的协议就是 http 和 https。
这听起来很显然,但显式写进边界很重要。诊断 API 不应该变成通用网络 fetcher。不能因为某个库能解析 file:、ftp:、gopher: 或其他应用自定义 scheme,就让公开 endpoint 接受它们。
端口也应该收敛。公开 HTTP 检查默认并不需要探测所有可能的端口。标准 Web 端口通常已经足够,任何更宽的端口支持都应该是明确的产品决策,并配套单独控制。
公开产品的边界应该足够朴素:
- 接受 Web URL 或域名
- 只通过 HTTP(S) 请求
- 禁止 URL 内账号密码
- 收窄端口范围
- 输入有歧义时默认拒绝
对公开工具来说,朴素边界本身就是功能。
先解析,再判断 IP 是否允许访问
SSRF 防护最关键的一步,不是只看 hostname,而是校验解析后的目的地址。
服务端真正连接之前,需要把目标 hostname 解析成一个或多个 IP 地址。然后检查这些 IP 是否落入应该阻断的范围,例如:
- loopback 地址
- RFC1918 私网地址
- link-local 地址
- IPv6 unique local 地址
- multicast 和保留地址段
- 云厂商实例 metadata 地址
这样可以避免公开调用者借助诊断服务器访问内部服务、metadata endpoint 或只在本机监听的端口。
错误响应应该让正常用户知道发生了什么,但不能泄露内部网络细节。比如“目标解析到了非公网地址”是有用的;把内部路由、具体地址和服务端网络上下文都返回出去,就没有必要。
跳转必须手动跟随并重新校验
Redirect 是很多 SSRF 防护最容易过度乐观的地方。
如果 HTTP client 自动跟随跳转,应用可能只校验了第一跳 URL。一个看起来正常的公网 URL 可以返回 302,把 client 带到内部地址,而应用已经没有机会重新检查。
对诊断 API 来说,跳转链本身也是证据。工具应该记录它,并展示给用户。因此更合理的做法是由应用显式跟随跳转:
- 请求当前 URL,但关闭自动跳转
- 读取
Locationheader - 基于当前 URL 规范化下一跳
- 再次校验协议、hostname 和端口
- 解析下一跳 hostname
- 连接前再次判断解析后的 IP 是否安全
- 设置较小的最大跳转次数
这样既保留了跳转链的诊断价值,也保证每一跳都在同一套安全边界内。
DNS rebinding 是更隐蔽的情况
DNS rebinding 让“我已经校验过 hostname 了”这句话变得不够可靠。
一个域名可以在校验时解析到公网 IP,随后又解析到私网 IP;也可能在不同查询之间返回不同答案。如果代码校验的是一次解析结果,而 HTTP client 内部连接时又重新解析了一次,那么真实连接目标可能已经不是刚才校验过的目标。
这类问题没有一行万能代码能覆盖所有运行时和 HTTP 栈。实际可行的做法是让解析和连接行为尽量靠近,在每个 redirect hop 上重新校验,对危险地址段保持一致拒绝,设置较短 timeout,并避免把未经控制的 hostname 直接交给会自动做很多事情的 client。
公开契约也不应该承诺“可以测试任意内部网络目标”。那是另一个产品,需要认证、授权和特定网络部署位置。
Timeout 和大小限制也是安全控制
SSRF 防护不只是控制服务端连接哪里,也要控制一次请求能消耗多少工作量。
公开诊断 API 应该设置:
- 连接和响应 timeout
- 最大跳转次数
- 最大响应体大小
- 稳定的按 route 限流
- 可预测的错误结构
这些限制既保护服务可用性,也让自动化更可靠。CI 任务或 AI Agent 可以处理结构化的 timeout 或 blocked target 错误,但很难处理一个永远挂起、或者返回巨大响应体的请求。
这也是 OpsKitPro 把 SSRF 防护和按成本分级的限流放在一起考虑的原因。API 既要拒绝危险目标,也要让正常但昂贵的探测保持在可持续预算内。
错误契约要清晰,但不要变成侦察工具
安全错误应该明确,但不能把内部信息暴露成排障材料。
好的公开 API 错误响应至少回答三个问题:
- 请求是在探测前被拒绝的吗?
- 这个问题用户是否可以修改输入后解决?
- 自动化脚本可以依赖哪个稳定错误码?
例如,被 SSRF 规则阻断的目标不应该表现成普通 500。它应该是一个受控的客户端侧拒绝,并使用稳定错误码,例如 TARGET_NOT_ALLOWED、UNSUPPORTED_PROTOCOL 或 REDIRECT_TARGET_BLOCKED。
这样脚本可以清楚分支:
{
"success": false,
"error": {
"code": "TARGET_NOT_ALLOWED",
"message": "The target resolves to a non-public network address."
}
}响应不需要暴露内部地址、解析路径或服务端网络环境。它只需要说明违反了哪条公开规则。
为什么这件事应该早于 API Key
很容易把安全工作推迟到付费账号、API Key 或企业版之后。
但对诊断 API 来说,这个顺序反了。
匿名公开 endpoint 是搜索流量、脚本、扫描器和 Agent 最先接触到的入口。API Key 可以识别用户,也可以支持配额,但它不会自动让服务端 outbound fetch 变安全。付费用户也可能误提交危险目标,泄露的 key 也可能被滥用。
更合理的顺序是:
- 先约束公开 endpoint 允许访问什么
- 让错误响应明确且可测试
- 按 endpoint 成本加限流
- 等产品压力真实出现后,再加 API Key 和付费配额
安全边界应该早于商业边界。
OPC 模式下的经验
对一人产品来说,SSRF 防护应该务实、清晰、朴素。
它不需要一开始就是宏大的安全平台。它需要定义公开 API 能访问什么,校验每一跳,拒绝非公网目标,限制单次请求工作量,并返回脚本能理解的错误。
做到这些,就可以让产品继续快速迭代,同时避免一个有用的诊断工具意外变成公开网络代理。