SSRF(Server-Side Request Forgery,服务端请求伪造)并不是“某个参数能访问内网”这么简单。实际审计中,漏洞通常分散在 URL 解析、请求库配置、重定向处理、代理环境变量和权限边界几个环节。只修补一个黑名单,往往只能降低部分风险,无法形成可靠的防护闭环。
本文以一次典型的文件预览接口为背景,整理从现象确认、代码定位、风险评估到修复验证的完整思路。示例均以本地测试环境和无敏感数据服务为前提,重点放在审计方法和工程化修复上。
一、问题背景与业务场景
某系统提供了远程文件预览功能,前端提交一个 URL,后端通过 HTTP 客户端下载内容并转换为预览结果。接口的简化逻辑如下:
public PreviewResult preview(String url) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<byte[]> response = client.send(
request,
HttpResponse.BodyHandlers.ofByteArray()
);
return converter.convert(response.body());
}从业务角度看,这段代码很直接;从安全角度看,它把用户可控字符串直接交给了 URL 解析器和网络客户端。只要服务端所在网络能够访问某个地址,攻击者就可能借助该接口让服务器代为发起请求。
这类问题的实际影响取决于部署环境,包括:
- 应用服务器是否能访问内部管理系统、监控系统或云平台元数据服务。
- 请求是否携带了默认认证头、代理身份或内部网络信任信息。
- 响应内容是否会原样返回给调用方。
- 应用是否允许跟随重定向,以及重定向后的地址是否重新校验。
- 网络出口和云平台是否有额外的访问控制。
二、审计时不要只盯着黑名单
很多修复方案停留在禁止若干字符串,例如拒绝包含 localhost、127.0.0.1 或内网网段的输入。这种方式的问题是,URL 解析存在多个语义层次,字符串检查和真正发起请求之间可能出现差异。
审计时建议沿着以下数据流追踪:
- 输入来源:参数、JSON 字段、HTTP Header、数据库任务或消息队列。
- 规范化过程:是否做了 URL 解码、大小写转换、默认端口补全。
- 解析过程:域名、端口、协议、用户名密码部分分别如何处理。
- DNS 解析:校验发生在解析前还是解析后,是否存在解析结果变化。
- 连接过程:是否经过代理,是否自动跟随重定向,是否复用连接池。
- 输出过程:响应头、响应体和错误信息是否会返回给外部调用者。
尤其需要注意“校验一次、请求一次”的模式。如果校验阶段解析出的地址与真正连接阶段解析出的地址不一致,单纯依靠一次域名判断就不够可靠。这里不需要把问题简化成某个特殊字符串,而应把它视为“目标地址验证与网络连接之间缺少一致性约束”。
三、建立可控的验证环境
在生产环境验证 SSRF 时,不建议直接探测内部系统。更稳妥的做法是搭建两个本地服务:一个模拟业务应用,另一个模拟允许访问的资源服务,并额外准备一个明确禁止访问的测试端点。通过访问日志确认请求是否到达,而不是依赖扫描或读取真实内部数据。
一个最小化的本地记录服务可以使用 Python 标准库实现:
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
print('received path:', self.path)
print('received headers:', dict(self.headers))
body = b'test-resource'
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
HTTPServer(('127.0.0.1', 18080), Handler).serve_forever()验证目标不是证明“能不能访问某个真实内网地址”,而是确认以下控制是否生效:
- 只允许明确的协议,例如仅允许 HTTPS。
- 只允许配置中的域名或资源标识。
- 解析出的所有地址都符合允许范围。
- 重定向不会把请求带到未授权地址。
- 连接失败、超时和解析异常不会泄露过多内部信息。
四、推荐的修复策略:从允许列表开始
如果业务并不需要访问任意 URL,最佳方案不是完善黑名单,而是取消任意 URL 能力。可以让用户提交资源 ID,由服务端根据固定配置选择目标;或者仅允许访问预先登记的域名和路径。
例如,业务只需要从对象存储读取公开文件,可以将外部输入改为对象键:
public byte[] loadObject(String objectKey) {
if (!OBJECT_KEY_PATTERN.matcher(objectKey).matches()) {
throw new IllegalArgumentException('invalid object key');
}
return objectStorage.getObject(
ConfiguredBucket.NAME,
objectKey
);
}如果确实必须接受 URL,则至少应执行以下步骤:
- 解析 URL,而不是对原始字符串做关键字匹配。
- 拒绝非必要协议,通常只保留 HTTPS。
- 拒绝包含用户名和密码的 URL,避免凭据混入请求。
- 明确限制端口,默认只允许 443,或使用业务配置中的固定端口。
- 对主机名做规范化处理,禁止空主机、异常编码和不符合预期的域名格式。
- 解析所有 A 和 AAAA 记录,并对每个地址进行网络范围校验。
- 连接前后保持目标约束,避免校验结果与实际连接对象不一致。
- 关闭自动重定向,或对每一跳重新执行完整校验。
- 设置连接超时、读取超时、响应大小上限和最大响应时间。
五、一个更完整的校验框架
下面的伪代码展示了校验顺序。它不是可直接复制到所有项目的通用库,但可以作为代码审计和单元测试的检查清单。
URI target = parseUserInput(input);
if (!allowedSchemes.contains(target.scheme())) {
throw new SecurityException('scheme is not allowed');
}
if (target.userInfo() != null) {
throw new SecurityException('userinfo is not allowed');
}
if (!allowedPorts.contains(effectivePort(target))) {
throw new SecurityException('port is not allowed');
}
String host = normalizeHost(target.host());
Set<IPAddress> addresses = resolver.resolveAll(host);
if (addresses.isEmpty() || addresses.stream().anyMatch(this::isRestrictedAddress)) {
throw new SecurityException('resolved address is not allowed');
}
HttpRequest request = buildRequest(target);
HttpResponse<byte[]> response = client.sendWithoutRedirect(request);
if (response.statusCode() is redirect) {
URI next = parseLocation(response);
validateAgain(next);
}实际实现中还要考虑代理设置。部分 HTTP 客户端会自动读取 HTTP_PROXY、HTTPS_PROXY 或 NO_PROXY 环境变量。如果审计人员只看业务代码,可能忽略请求最终经过了代理。生产配置应明确代理策略,并在测试中验证环境变量、系统代理和客户端默认行为是否符合预期。
六、容易遗漏的边界问题
1. 重定向
即使第一跳目标通过了校验,服务端也可能收到 301、302、307 或 308 响应。若客户端自动跟随重定向,最终目标可能已经发生变化。修复时应优先关闭自动重定向;如果业务必须支持重定向,则每一跳都要限制协议、主机、端口和解析地址,并设置最大跳转次数。
2. DNS 解析与地址校验
只校验域名字符串并不能证明连接目标安全。域名可能解析到多个地址,也可能同时存在 IPv4 和 IPv6 记录。校验逻辑必须覆盖所有解析结果,不能因为其中一个地址合法就忽略其他地址。
3. IPv6 与地址格式
很多内部地址判断只覆盖 IPv4 私有网段,却没有处理 IPv6 回环地址、链路本地地址和本地接口地址。地址判断应使用成熟的 IP 解析库,而不是通过字符串前缀比较。对 IPv4 映射的 IPv6 地址也要进行统一归一化。
4. 请求头与凭据传播
不要把用户请求的 Authorization、Cookie 或内部鉴权头无条件转发到目标地址。代理层、SDK 和全局 HTTP 拦截器都可能自动添加请求头。建议建立明确的出站请求头白名单,只保留业务真正需要的字段。
5. 响应处理
响应体应设置大小上限,并根据业务需要限制 Content-Type。错误信息不要直接返回底层异常堆栈,因为其中可能包含解析地址、代理信息、文件路径和内部服务名称。对于文件转换场景,还应单独审计解压缩、解析器和临时文件目录。
七、测试用例应覆盖哪些情况
安全修复是否可靠,不能只用一个正常 URL 测试。建议把校验逻辑拆成独立组件,并建立表格化测试:
| 类别 | 测试内容 | 期望结果 |
| 协议 | 允许协议、未知协议、大小写混合协议 | 仅允许业务配置中的协议 |
| 端口 | 默认端口、显式允许端口、非允许端口 | 非允许端口拒绝 |
| 解析 | 单地址、多地址、IPv4 与 IPv6 混合 | 任一地址不符合策略即拒绝 |
| 跳转 | 跳转到同域名、其他域名、禁止地址 | 每一跳重新校验 |
| 网络 | 连接超时、读取超时、超大响应 | 在限定时间和大小内终止 |
| 请求头 | 外部 Cookie、Authorization、代理头 | 不向目标地址传播敏感头 |
除了单元测试,还应使用本地记录服务做集成测试,确认真实 HTTP 客户端的行为与假设一致。特别要验证重定向、代理环境变量、DNS 缓存和连接池复用等场景。
八、日志与监控建议
安全日志应记录请求是否被策略拒绝、目标域名的脱敏形式、解析结果类别、失败原因和请求耗时,但不要记录完整的 Authorization、Cookie 或可能包含敏感信息的 URL 查询参数。
可以重点监控以下异常信号:
- 同一账号短时间内请求大量不同域名。
- 请求频繁触发协议、端口或地址范围校验失败。
- 大量连接超时、解析失败或响应大小超限。
- 业务正常流量之外出现异常的重定向链。
- 出站请求目标集中在不常见的网络区域。
监控的目的不是替代代码修复,而是帮助发现绕过路径和误配置。日志中应避免直接打印用户提交的完整 URL,防止日志成为新的敏感信息泄露源。
九、复盘结论
这次问题的根因不是某一个危险字符串没有加入黑名单,而是业务把“用户输入”直接提升成了“服务端网络访问目标”,同时缺少协议、地址、重定向、响应和出站凭据的统一约束。
从工程实践看,优先级可以按以下顺序安排:
- 能取消任意 URL,就改成资源 ID 或固定目标。
- 不能取消时,采用严格允许列表,而不是不断扩展黑名单。
- 把 URL 解析、DNS 解析、地址判断和重定向控制放在同一安全组件中。
- 限制协议、端口、响应大小、超时和出站请求头。
- 通过单元测试和本地集成环境验证真实客户端行为。
- 配合出站网络策略进行纵深防御,避免应用层失误直接触达敏感网络。
SSRF 防护的关键不在于记住多少种地址写法,而在于建立一条可验证的控制链:输入被规范化,目标被明确授权,解析结果被完整检查,实际连接受到约束,响应和凭据不会越权传播。只有这几个环节同时成立,修复才具有长期稳定性。
推荐意见