在实际代码审计中,SSRF(服务端请求伪造)往往不是一个单独的“请求外部 URL”问题,而是输入校验、URL 解析、DNS 解析、重定向处理和网络出口策略共同形成的风险。很多修复方案只增加一个字符串前缀判断,短期内能够挡住已知测试用例,但在解析差异、重定向或 DNS 变化场景下仍然可能失效。
本文以一个常见的文件预览接口为例,整理从发现线索、确认数据流、分析风险边界,到修复和验证的完整过程。示例只用于本地测试环境和防守性分析,不涉及对第三方系统的访问。
一、背景与问题场景
某应用提供了一个“通过 URL 获取远程文件并生成预览”的功能,业务代码大致包含以下逻辑:
String source = request.getParameter("source");
byte[] data = httpClient.get(source);
return previewService.generate(data);从业务角度看,这段代码很直接:接收地址、发起请求、处理响应。但从安全角度看,服务器实际上成为了用户可控的网络请求代理。攻击者可能借此让服务器访问本不应由用户直接访问的内部服务、管理接口或云环境元数据服务。
需要注意的是,SSRF 的影响取决于运行环境和网络拓扑。普通办公系统可能主要面临内网资产探测风险;运行在云环境中的服务,还需要重点关注实例元数据、容器管理接口和服务间信任关系。
二、审计时先确认完整数据流
不要看到“接收 URL 并请求”就直接下结论。更可靠的做法是沿着输入数据流,确认以下几个问题:
- URL 是否完全由用户控制,还是只允许选择服务器端预置的资源。
- 请求由哪一层发起,是业务代码、反向代理、图片处理组件,还是第三方 SDK。
- 是否支持 HTTP、HTTPS 以外的协议或自定义 Scheme。
- 是否自动跟随 3xx 重定向,重定向后的地址是否重新校验。
- 是否存在代理配置,解析请求和实际连接是否经过不同网络路径。
- 响应内容是否会进入 XML、图片、压缩包或模板解析流程,形成二次风险。
在代码审计中,建议同时搜索请求库的高风险调用点和业务封装层。常见关键词包括 new URL、HttpClient、requests.get、urllib、OkHttp、followRedirects、openConnection 以及自定义的下载、预览、回调、导入接口。
三、为什么简单黑名单经常失效
最常见的初步修复是拒绝字符串中出现某些内网地址,例如检查是否包含 127.0.0.1、localhost 或某段私有地址。这种方式的问题在于,字符串形式和实际连接目标不是一回事。
- 多种地址表示形式:同一个 IPv4 地址可能以十进制、十六进制、八进制或 IPv6 映射形式出现,不同语言和库的解析行为也可能不同。
- 主机名解析变化:URL 中看到的是域名,真正连接时需要经过 DNS 解析。域名可能解析到公网地址,也可能在不同时间解析到内网地址。
- 重定向绕过:初始地址是允许的公网地址,但服务端返回重定向后,客户端继续访问了未经校验的新地址。
- 解析器差异:业务代码使用一种 URL 解析器做校验,请求库使用另一种解析器建立连接,可能出现端口、用户名信息、编码和主机名解析差异。
- 代理与直连差异:应用可能通过 HTTP 代理访问外部网络,校验看到的目标和代理最终访问的目标并不完全一致。
防守侧的基本原则是:先解析成结构化对象,再校验协议、主机、端口和最终连接地址;不要依赖字符串包含或简单正则表达式。
四、建立安全的请求边界
相较于维护不断扩大的黑名单,更推荐采用“默认拒绝、明确允许”的策略。对于远程资源获取功能,可以按以下顺序建立边界。
1. 限制协议
只允许业务真正需要的协议,通常是 HTTPS,必要时再兼容 HTTP。应拒绝文件、FTP、Gopher、JAR 等不需要的 Scheme,并确保大小写、编码和解析异常不会改变判断结果。
URI uri = URI.create(input);
String scheme = uri.getScheme();
if (scheme == null || !(scheme.equalsIgnoreCase("https") || scheme.equalsIgnoreCase("http"))) {
throw new IllegalArgumentException("unsupported scheme");
}示例仅展示校验思路,生产代码还应处理非法 URI、空主机、用户信息、异常端口和控制字符等情况。
2. 校验主机名与端口
业务允许访问的目标如果是固定的对象存储域名或合作方域名,优先使用精确允许列表,而不是允许任意公网地址。例如只允许 download.example.org 和经过审核的静态资源域名,并将端口限制为 443,必要时兼容 80。
如果业务确实需要访问多个域名,应明确域名归属和用途,避免使用宽泛的后缀匹配。诸如“以 example.org 结尾”的判断,必须考虑子域名边界、尾随点、大小写、国际化域名和 CNAME 指向问题。
3. 解析 DNS 并检查连接地址
仅校验域名文本是不够的。应用在建立连接前,应解析域名对应的所有地址,并拒绝回环地址、链路本地地址、未指定地址、私有地址以及企业网络中明确禁止访问的网段。
同时要关注 IPv4 和 IPv6。很多实现只判断 IPv4 私网范围,却忽略 IPv6 的回环、链路本地和本地唯一地址。判断函数应基于成熟网络库提供的地址类型,而不是自己拼正则。
for (InetAddress address : InetAddress.getAllByName(host)) {
if (isLoopback(address) || isLinkLocal(address) || isPrivate(address) || isUnspecified(address)) {
throw new SecurityException("destination is not allowed");
}
}这里的 isPrivate 等函数需要结合项目使用的网络库实现,不能假设所有 JDK、语言版本或第三方库的语义完全一致。
4. 禁止或严格控制自动重定向
安全实现通常先关闭自动重定向,由业务代码控制跳转次数。每次收到 3xx 响应时,重新解析 Location,重新执行协议、域名、端口和地址检查。
- 限制最大重定向次数,通常不超过 3 次。
- 要求重定向目标仍属于允许的协议和主机范围。
- 必要时禁止从 HTTPS 降级到 HTTP。
- 记录原始 URL、每次跳转目标、最终连接地址和响应状态,便于审计。
五、一个更完整的安全请求流程
可以把请求过程拆成“规范化、策略校验、解析校验、建立连接、响应处理”五个阶段,避免把所有逻辑堆在一个工具方法里。
public Response fetch(String input) {
URI uri = parseAndNormalize(input);
validateScheme(uri);
validateUserInfoAndPort(uri);
validateAllowedHost(uri.getHost());
List<InetAddress> addresses = resolve(uri.getHost());
for (InetAddress address : addresses) {
validateDestination(address);
}
HttpClient client = buildClientWithoutAutomaticRedirects();
Response response = client.execute(uri);
if (isRedirect(response)) {
URI next = parseLocation(response);
validateRedirect(next);
return fetchWithLimitedRedirects(next);
}
validateResponse(response);
return response;
}实际项目中不建议简单递归实现重定向流程,应显式传递跳转次数、已访问地址集合和总超时时间,防止循环跳转或资源消耗。
六、请求库配置同样重要
代码逻辑正确,但底层客户端配置不当,仍可能扩大风险。建议重点检查以下配置:
- 连接超时、读取超时和整体请求超时,避免请求长期占用线程。
- 响应体最大大小,避免通过大文件消耗内存或磁盘。
- 连接池大小,防止大量慢请求拖垮业务线程池。
- 是否启用自动重定向、压缩解压和代理。
- TLS 证书校验是否被关闭,是否存在自定义 Host 头。
- 是否允许连接到本地 Unix Socket、特殊协议或非标准端口。
对于图片、文档和压缩包预览功能,还应将下载和解析隔离到低权限、受限网络的 worker 中。网络访问层的修复不能代替解析器沙箱,反过来也一样。
七、网络出口控制是最后一道防线
应用层校验存在复杂边界时,网络层策略可以显著降低风险。建议在主机、容器或云网络层面实施以下控制:
- 默认禁止业务容器访问管理网段、数据库网段和云平台元数据地址。
- 通过出站防火墙或安全组限制可访问的目标网段和端口。
- 将外部下载任务放入独立网络区域,只允许访问审核后的代理或资源域名。
- 对异常 DNS 查询、短时间大量不同目标连接和非业务端口访问进行告警。
- 禁止应用直接读取宿主机敏感文件或访问容器运行时管理接口。
网络隔离不能替代输入校验,但能够避免单一代码缺陷直接演变为横向访问或敏感服务暴露。
八、验证修复是否真正生效
验证时不要只准备一个“被拦截”的输入,而应覆盖整个请求生命周期。可以在本地搭建两个简单服务:一个作为业务服务,另一个作为可控的测试目标,通过日志观察解析和连接行为。
- 合法 HTTPS 资源能够正常获取。
- 缺少协议、主机为空、端口异常的输入会被拒绝。
- 回环、私有、链路本地和未指定地址不会建立连接。
- 解析到多个地址时,只要存在禁止地址就不会继续请求。
- 允许的初始地址重定向到禁止目标时,跳转会被拦截。
- 超过最大响应大小或超时后,请求能够及时释放资源。
- IPv4 和 IPv6 地址的判断结果符合预期。
- 异常输入不会导致日志注入、线程池耗尽或错误信息泄露。
如果项目采用代理,还要分别验证直连和代理模式下的行为。应在测试日志中记录“策略判断目标”和“实际连接目标”,确认两者没有出现不可解释的偏差。
九、日志与监控建议
SSRF 风险的发现和追踪离不开足够的日志,但日志中不应直接记录可能包含凭据的完整 URL。建议对 URL 做脱敏或仅记录协议、规范化主机、端口、策略结果和请求编号。
- 记录请求来源接口、用户或服务身份、目标域名、目标端口和拒绝原因。
- 记录 DNS 解析结果的类型和数量,不必记录全部敏感参数。
- 区分策略拒绝、DNS 失败、连接超时、TLS 失败和响应超限。
- 对同一调用方短时间访问大量不同域名、频繁触发内网地址拒绝进行告警。
- 将应用日志与出口防火墙、DNS 和代理日志关联,便于事件复盘。
十、常见错误修复方式
- 只检查 URL 前缀:没有处理重定向、DNS 解析和地址表示差异。
- 只拦截少量内网网段:遗漏 IPv6、链路本地、企业自定义网段或云环境特殊地址。
- 校验后再次解析:校验阶段和连接阶段之间存在 DNS 变化,导致检查结果失效。
- 只修业务代码:图片处理、文档转换、Webhook 或第三方 SDK 仍然可以发起网络请求。
- 依赖错误信息判断成功与否:不同请求库对连接失败、代理失败和响应失败的异常类型并不一致。
- 没有限制资源消耗:即使目标地址安全,大响应体、慢连接和大量重定向也可能造成拒绝服务。
十一、审计工作中的实用方法
面对大型项目时,可以先建立“请求能力清单”,按数据来源和出网能力进行排序:
- 第一优先级:用户直接控制 URL,服务端自动访问并返回内容。
- 第二优先级:Webhook、回调地址、导入地址、远程图片和在线预览。
- 第三优先级:只允许固定域名,但存在重定向、DNS 自解析或代理配置。
- 第四优先级:由后台任务异步访问外部资源,需要结合队列权限和网络区域分析。
代码审计阶段重点确认数据流和边界,测试阶段重点确认实际网络行为,修复阶段同时落地应用层策略和网络层限制。三者缺一不可。
总结
SSRF 的核心不是“禁止某个 IP”,而是控制服务端可访问的网络能力。可靠的防护应至少包括:限制协议和端口、使用明确的主机允许列表、解析并校验所有目标地址、关闭或严格控制重定向、限制请求资源、隔离网络出口,并通过日志和测试验证最终连接行为。
对于长期维护的系统,建议把这些规则沉淀为统一的安全请求组件,禁止业务代码自行拼装网络请求。每当新增远程导入、预览、回调或文件处理功能时,都应复用同一套策略,并将网络出口配置纳入变更评审。这样才能避免“某个接口修好了,另一个接口又重新引入同类问题”。
推荐意见