URL 预览、网页截图、远程文件导入、Webhook 测试等功能,通常都需要由服务端主动访问用户提交的地址。这类功能本身并不危险,但一旦缺少严格的目标校验,就可能演变为服务端请求伪造(SSRF)。
SSRF 的风险不只在于“访问了一个不该访问的 URL”,更在于请求发生在服务器所在的网络环境中:服务器可能具备访问内网管理面板、服务注册中心、云平台元数据接口或本地调试端口的权限。因此,审计这类问题时,重点不是寻找一个能发起请求的接口,而是完整分析“输入如何到达网络连接点”,以及应用是否在每个跳转和解析阶段持续执行安全策略。
一、典型场景与审计目标
常见代码形态包括:
- 用户提交 URL,后端抓取标题并生成预览卡片;
- 后台导入远程图片、压缩包或文档;
- Webhook 配置页面允许管理员测试回调地址;
- 服务端根据 URL 生成二维码、截图或转码结果;
- 代码审计中出现 HTTP 客户端、文件下载器或 XML 外部实体解析器。
审计目标可以拆成四个问题:
- 外部输入是否能够影响请求目标、协议、端口或请求方法?
- 应用是否允许访问本机、内网、环回地址、链路本地地址或 Unix Socket?
- 域名解析、重定向和连接建立之间是否存在校验失效?
- 即使请求发出,响应内容、响应头和错误信息是否会回显给用户?
二、从数据流定位危险调用点
实际审计中,不建议只搜索 requests.get 或 HttpClient。更稳定的方法是沿着数据流从输入源追踪到网络连接点。
常见输入源包括 HTTP 参数、JSON 字段、数据库中的可编辑配置、消息队列内容和后台任务参数。危险汇点则包括 HTTP 客户端、浏览器自动化框架、FTP 客户端、Redis 或数据库客户端,以及支持远程资源加载的解析器。
可以先进行一次粗粒度搜索,再回到调用链确认是否存在实际可控性:
grep -RInE "requests\.(get|post|request)|urlopen|HttpClient|RestTemplate|WebClient|axios|fetch\(" src/随后重点确认以下几点:
- URL 是否来自用户输入,还是仅来自固定配置;
- 是否存在统一的
validateUrl、isAllowedHost等校验函数; - 校验是在 DNS 解析前还是解析后执行;
- HTTP 客户端是否自动跟随重定向;
- 是否对响应大小、连接超时和读取超时做了限制。
三、为什么简单的黑名单经常失效
很多项目最初会采用字符串黑名单,例如禁止 127.0.0.1、localhost 或某几个内网网段。这种方案的问题在于,URL 解析、域名解析和实际建连是三个不同阶段,字符串层面的判断无法覆盖全部表示形式。
需要特别关注以下边界:
- IP 表示差异:同一个地址可能存在十进制整数、十六进制、IPv6、IPv4 映射 IPv6 等表示形式。
- 域名解析变化:域名在校验时解析到公网地址,实际建立连接时可能解析到不同地址。
- 重定向:初始 URL 是允许的,但响应的
Location指向了受限网络。 - 非 HTTP 协议:部分客户端支持
file、gopher、ftp等协议,导致风险边界扩大。 - 端口绕过:只限制主机而不限制端口,可能将请求打到内部管理服务。
- 代理配置:客户端可能读取环境变量中的代理设置,实际流量并未按预期路径发送。
安全校验应针对“最终建立的连接”执行,而不是只针对用户提交的原始字符串执行。
四、推荐的安全校验流程
一个相对稳妥的 URL 访问流程可以分为以下步骤:
- 解析 URL,并拒绝缺少主机、端口异常或格式不规范的输入;
- 只允许业务明确需要的协议,通常仅保留 HTTPS,必要时再开放 HTTP;
- 限制端口到明确的允许集合,例如只允许 443 或 80;
- 解析域名得到全部 A 和 AAAA 记录;
- 对每一个解析结果执行公网地址校验,拒绝环回、私有、链路本地、组播和未指定地址;
- 建立连接时固定使用已校验的地址,避免校验和建连之间再次发生 DNS 变化;
- 处理每次重定向前重新执行完整校验;
- 限制响应大小、连接时间、读取时间和最大重定向次数;
- 关闭不必要的协议升级、代理继承和本地文件访问能力。
下面是一个用于说明校验思路的 Python 示例。它只负责判断目标地址是否属于允许范围,不包含实际下载逻辑:
from ipaddress import ip_address, ip_network
from urllib.parse import urlparse
import socket
ALLOWED_SCHEMES = {"https"}
ALLOWED_PORTS = {443}
BLOCKED_NETWORKS = [
ip_network("0.0.0.0/8"),
ip_network("10.0.0.0/8"),
ip_network("100.64.0.0/10"),
ip_network("127.0.0.0/8"),
ip_network("169.254.0.0/16"),
ip_network("172.16.0.0/12"),
ip_network("192.168.0.0/16"),
ip_network("::/128"),
ip_network("::1/128"),
ip_network("fc00::/7"),
ip_network("fe80::/10"),
]
def is_public_ip(value: str) -> bool:
ip = ip_address(value)
return not any(ip in network for network in BLOCKED_NETWORKS)
def validate_target(raw_url: str):
parsed = urlparse(raw_url)
if parsed.scheme not in ALLOWED_SCHEMES:
raise ValueError("unsupported scheme")
if not parsed.hostname or parsed.username or parsed.password:
raise ValueError("invalid authority")
port = parsed.port or 443
if port not in ALLOWED_PORTS:
raise ValueError("unsupported port")
results = socket.getaddrinfo(
parsed.hostname,
port,
type=socket.SOCK_STREAM,
)
addresses = {item[4][0] for item in results}
if not addresses or not all(is_public_ip(addr) for addr in addresses):
raise ValueError("target is not public")
return parsed, addresses这个示例仍然不是完整的生产实现。生产环境中还需要让实际连接使用已经校验过的地址,并正确设置 TLS 的主机名校验。否则,单独调用 DNS 进行检查,随后再让 HTTP 客户端重新解析域名,仍然可能出现检查与建连不一致的问题。
五、DNS 解析与连接固定
SSRF 防护中比较容易被忽略的是 DNS 的时间窗口。典型的不安全流程如下:
解析域名 -> 判断为公网地址 -> 交给 HTTP 客户端 -> HTTP 客户端再次解析域名并连接问题在于,第二次解析的结果可能与第一次不同。更可靠的做法是:
- 在应用层解析并校验所有地址;
- 从校验结果中选择一个地址建立 TCP 连接;
- TLS 握手时仍使用原始域名作为 SNI 和证书校验名称;
- 连接失败时,不应无条件重新解析并尝试未校验地址;
- 对 DNS 响应设置合理的超时和数量上限。
不同语言的 HTTP 客户端实现差异较大,建议优先使用支持自定义 DNS Resolver、Dialer 或连接器的库,并阅读其重定向、代理和地址选择逻辑。不要仅凭函数名判断其是否安全。
六、重定向、响应和解析器风险
1. 重定向必须重新校验
如果业务不需要重定向,最简单的方案是直接关闭自动跟随。确实需要跟随时,应限制次数,并对每个 Location 目标重新执行协议、端口、域名解析和地址范围校验。
for redirect_count in range(MAX_REDIRECTS + 1):
target, resolved = validate_target(current_url)
response = fetch_once(target, resolved, timeout=5)
if response.status_code not in {301, 302, 303, 307, 308}:
break
next_url = response.headers.get("Location")
if not next_url:
break
current_url = resolve_relative_url(current_url, next_url)
else:
raise ValueError("too many redirects")2. 限制响应体和内容类型
很多预览功能只需要状态码、标题或少量正文,却直接把整个响应读入内存。这会带来内存消耗、压缩炸弹和超大文件风险。建议设置:
- 连接超时、读取超时和总耗时;
- 最大响应体大小;
- 最大响应头数量和单个头长度;
- 允许的 Content-Type;
- 禁止自动解压,或对解压后的大小再次限制。
3. 不要忽略其他远程资源加载点
XML、SVG、Office 文档、图片处理库和浏览器自动化框架都可能主动加载外部资源。代码审计时,应确认 XML 外部实体、XInclude、远程字体、脚本、iframe 和浏览器网络请求是否被关闭或隔离。
七、一个安全的复现与验证方法
在授权测试或内部开发环境中,验证 SSRF 防护不需要访问真实内网服务。可以搭建两个本地测试服务:
- 公开测试服务:记录请求方法、路径和请求头;
- 受限测试服务:用于确认应用是否错误访问了不应访问的目标。
测试重点不是读取敏感内容,而是观察应用是否:
- 拒绝不支持的协议和端口;
- 拒绝解析到受限地址的域名;
- 在重定向后再次执行校验;
- 正确处理 IPv4、IPv6 和多 A 记录;
- 在超时或超大响应下及时中止;
- 不向前端回显内部连接错误、响应头或完整响应体。
建议将这些情况写成自动化测试,避免后续更换 HTTP 客户端或修改代理配置时防护退化。
八、日志与检测建议
SSRF 防护不仅是代码问题,也需要配合可观测性。建议记录以下字段:
- 请求任务或业务操作的唯一标识;
- 规范化后的目标域名和端口;
- 解析到的地址类别,但避免记录不必要的敏感响应内容;
- 拒绝原因,例如协议不允许、地址属于私网或重定向超限;
- 连接耗时、响应大小和最终状态码;
- 重定向次数和最终失败阶段。
日志中不要直接记录包含凭据的完整 URL,也不要把远端响应原样写入日志。对于短时间内大量请求不同内网地址、频繁触发拒绝规则或持续访问异常端口的行为,可以结合账号、任务和源地址进行告警。
九、常见修复误区
| 误区 | 问题 | 改进方式 |
|---|---|---|
| 只拦截 localhost | 无法覆盖私有地址、IPv6 和特殊地址 | 基于标准 IP 类型和地址范围统一判断 |
| 只校验原始字符串 | 忽略 DNS 解析和连接阶段 | 校验所有解析结果,并固定已校验连接 |
| 允许自动重定向 | 第二跳可能进入受限网络 | 关闭重定向或逐跳重新校验 |
| 仅限制响应状态码 | 仍可能下载超大响应或触发解析器风险 | 同时限制大小、类型和处理时间 |
| 依赖前端校验 | 请求可被直接构造,前端规则不可信 | 所有策略必须在服务端执行 |
| 只修复单个接口 | 其他下载、截图、导入功能可能复用同类缺陷 | 建立统一的安全访问组件和代码规范 |
十、总结
SSRF 的核心不是某个特殊 URL 写法,而是服务端把不可信输入带入了网络访问能力。可靠的治理方式应同时覆盖输入解析、协议限制、DNS 解析、连接建立、重定向、响应处理和网络出口控制。
从工程实践看,最值得优先落实的是三点:第一,所有远程访问统一经过安全组件,不让业务代码自行拼装请求;第二,默认拒绝私有、环回、链路本地和未明确允许的地址,并在每次重定向时重新校验;第三,在网络层增加出口白名单和隔离策略,把应用层遗漏的风险限制在较小范围内。
代码审计时,如果只看到一个“允许用户填写 URL”的字段,不要马上下结论。应继续追踪它是否进入 DNS、HTTP、浏览器或文件解析器,确认校验发生的位置以及最终连接使用的地址。只有把完整数据流和实际网络行为对应起来,才能判断风险是否真实存在,也才能给出不会被轻易绕过的修复方案。
推荐意见