跳转到帖子

背景

SSRF(Server-Side Request Forgery)在企业内部系统里并不少见,尤其是带有“URL 预览、远程图片抓取、Webhook 回调、文件导入、第三方接口代理”等能力的业务。它的问题不在于某一行代码有多复杂,而是服务端代替用户发起了网络请求,如果边界没收紧,就可能访问到本不该暴露的内部资源。

这篇文章整理一次比较典型的 Java Web 代码审计过程,重点放在识别风险点、分析常见绕过、设计可落地的修复方案。内容偏防守视角,不涉及对真实目标的攻击操作。

场景描述

业务里有一个“远程资源导入”功能,用户提交一个 URL,后端拉取资源并保存到对象存储。简化后的代码如下:

@PostMapping("/import")
public ResponseEntity<String> importResource(@RequestParam String url) throws Exception {
    RestTemplate restTemplate = new RestTemplate();
    byte[] data = restTemplate.getForObject(url, byte[].class);
    String objectKey = storageService.save(data);
    return ResponseEntity.ok(objectKey);
}

从功能上看很直接,但从安全角度看,风险也很明显:用户可控的 URL 被服务端直接请求,没有协议限制、没有目标地址校验、没有重定向控制、没有响应大小限制,也没有请求超时策略。

风险点分析

1. 协议未限制

很多代码只判断字符串是否以 http 开头,甚至完全不判断协议。这样容易引入非预期协议访问风险。即使当前 HTTP 客户端默认只支持 HTTP/HTTPS,也不建议把安全假设建立在库的默认行为上。

建议明确只允许:

  • http
  • https

其他协议直接拒绝。

2. 域名与 IP 校验缺失

SSRF 的核心问题通常是访问内部地址,例如内网网段、本机地址、链路本地地址等。审计时需要重点检查是否存在以下情况:

  • 允许访问 127.0.0.1、localhost
  • 允许访问 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16
  • 允许访问 169.254.0.0/16 这类链路本地地址
  • 只校验域名字符串,不校验解析后的 IP
  • 只校验第一次请求,不校验重定向后的目标

仅靠黑名单字符串拦截通常不可靠,因为域名解析、IPv6、十进制/八进制 IP 表示、重定向等都会让判断变复杂。更稳妥的方式是做“允许列表”或解析后的地址校验。

3. 重定向未重新校验

不少 HTTP 客户端会自动跟随 30x 重定向。如果业务只校验初始 URL,没有校验跳转后的 URL,那么校验就可能失效。

更安全的做法是关闭自动重定向,手动处理每一次跳转,并在每次跳转前重新执行完整校验,同时限制跳转次数。

4. 响应大小与超时缺失

即使没有访问内部资源,攻击者也可能提交一个响应极慢或响应体极大的地址,导致线程阻塞、内存占用升高,最终影响服务稳定性。因此 SSRF 防护不只关心“访问哪里”,也要关心“请求多久、下载多少”。

建议至少设置:

  • 连接超时
  • 读取超时
  • 最大响应体大小
  • 最大重定向次数
  • 允许的 Content-Type 范围

审计思路

做 Java Web 代码审计时,可以从“用户输入到网络请求”的数据流入手。常见危险调用包括:

  • RestTemplate#getForObject
  • RestTemplate#exchange
  • HttpURLConnection#connect
  • OkHttpClient#newCall
  • Apache HttpClient#execute
  • URL#openStream
  • ImageIO#read(URL)

可以先用代码搜索定位关键调用:

grep -R "RestTemplate\|openStream\|HttpURLConnection\|OkHttpClient\|ImageIO.read" -n src/main/java

定位后再向上追踪参数来源,判断 URL 是否来自用户输入、配置中心、消息队列、数据库字段或第三方回调。很多风险并不直接出现在 Controller 层,而是在异步任务、导入任务、图片处理服务中。

关键修复方案

1. 使用 URI 解析而不是字符串拼接判断

不要用简单的 startsWith 或正则处理完整 URL。应使用标准 URI 解析,提取 scheme、host、port 等字段后再判断。

URI uri = URI.create(inputUrl);
String scheme = uri.getScheme();
String host = uri.getHost();

if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
    throw new IllegalArgumentException("unsupported scheme");
}

if (host == null || host.isBlank()) {
    throw new IllegalArgumentException("invalid host");
}

2. 解析域名并校验所有 IP

如果允许用户提交域名,应解析该域名对应的所有 IP,并逐一判断是否落在禁止访问的地址范围内。

private boolean isPrivateOrLocalAddress(InetAddress address) {
    return address.isAnyLocalAddress()
            || address.isLoopbackAddress()
            || address.isLinkLocalAddress()
            || address.isSiteLocalAddress()
            || address.isMulticastAddress();
}

private void validateHost(String host) throws UnknownHostException {
    InetAddress[] addresses = InetAddress.getAllByName(host);
    for (InetAddress address : addresses) {
        if (isPrivateOrLocalAddress(address)) {
            throw new IllegalArgumentException("target address is not allowed");
        }
    }
}

这里的示例主要覆盖常见本地、内网、链路本地地址。实际生产环境还应根据网络架构补充公司内部网段、云厂商元数据地址、办公网段等。

3. 优先采用业务允许列表

如果业务场景明确,例如只允许导入几个合作方的资源,最好直接使用域名允许列表。相比维护复杂黑名单,允许列表更容易解释和验证。

private static final Set<String> ALLOWED_HOSTS = Set.of(
    "static.example.com",
    "assets.partner.example"
);

private void validateAllowedHost(String host) {
    String normalized = host.toLowerCase(Locale.ROOT);
    if (!ALLOWED_HOSTS.contains(normalized)) {
        throw new IllegalArgumentException("host is not allowed");
    }
}

如果需要支持子域名,也建议明确后缀匹配规则,避免 example.com.evil.test 这类误判。可以采用“等于主域名或以点加主域名结尾”的方式:

private boolean matchDomain(String host, String domain) {
    host = host.toLowerCase(Locale.ROOT);
    domain = domain.toLowerCase(Locale.ROOT);
    return host.equals(domain) || host.endsWith("." + domain);
}

4. 禁止自动重定向,逐跳校验

使用 Apache HttpClient 时,可以关闭自动重定向,再根据响应头中的 Location 手动处理。每一次跳转都要重新解析 URL、校验协议、校验 Host、解析 IP,并限制跳转次数。

RequestConfig config = RequestConfig.custom()
        .setConnectTimeout(3000)
        .setResponseTimeout(5000)
        .setRedirectsEnabled(false)
        .build();

CloseableHttpClient client = HttpClients.custom()
        .setDefaultRequestConfig(config)
        .disableRedirectHandling()
        .build();

重定向处理建议控制在 3 次以内。超过次数直接失败,不要为了兼容性无限跳转。

5. 限制响应体大小

不要直接把响应一次性读入内存。可以边读边统计大小,超过阈值立即中断。

private byte[] readWithLimit(InputStream input, int maxBytes) throws IOException {
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    byte[] buffer = new byte[8192];
    int total = 0;
    int n;

    while ((n = input.read(buffer)) != -1) {
        total += n;
        if (total > maxBytes) {
            throw new IOException("response too large");
        }
        output.write(buffer, 0, n);
    }
    return output.toByteArray();
}

具体阈值要结合业务,例如头像类资源可以限制到几 MB,普通文档导入可以再放宽,但不建议无限制。

一套相对完整的校验流程

可以把服务端请求外部 URL 的流程整理成固定模板,避免不同业务各写一套半成品校验:

  • 解析 URI,拒绝非法 URL
  • 只允许 HTTP/HTTPS
  • 校验 Host 是否在允许范围内
  • 解析 Host 对应的全部 IP
  • 拒绝本地地址、内网地址、链路本地地址和公司内部敏感网段
  • 设置连接超时和读取超时
  • 关闭自动重定向,逐跳重新校验
  • 限制最大响应大小
  • 限制允许的响应类型
  • 记录审计日志,便于排查异常请求

测试用例建议

修复后建议补充单元测试和集成测试。测试重点不是“能不能请求成功”,而是边界是否被正确拒绝。

测试项预期结果
HTTP/HTTPS 正常公网域名允许,前提是符合业务允许范围
file、ftp 等非 HTTP 协议拒绝
localhost、本机回环地址拒绝
内网 IPv4 地址拒绝
IPv6 本地地址拒绝
公网地址重定向到内网地址拒绝
响应体超过限制中断并返回失败
连接超时或读取超时失败且不阻塞主流程

如果团队内有统一安全测试环境,可以搭一个只用于验证的本地 HTTP 服务,模拟重定向、超大响应、慢响应等情况。注意测试目标应限定在本地或授权环境内。

日志与监控

SSRF 类问题修复后,也建议保留必要的审计日志。日志不要记录完整敏感参数,但应能支持排查:

  • 调用方用户或业务标识
  • 原始 Host
  • 解析后的 IP 列表
  • 最终请求结果
  • 失败原因
  • 耗时与响应大小

如果短时间内出现大量被拒绝的内网地址、异常协议、重定向失败,可以作为安全告警信号。

常见误区

  • 只用正则判断 URL 格式,未解析真实 Host。
  • 只拦截 127.0.0.1,忽略 IPv6、本机名、内网网段。
  • 只校验第一次请求,忽略重定向。
  • 认为业务在内网就安全,实际上服务端能访问的资源往往更多。
  • 直接复用通用 HTTP 客户端,没有设置超时和大小限制。
  • 把 SSRF 防护写在某个 Controller,其他异步任务继续裸请求。

总结

SSRF 的修复重点不是单个判断条件,而是建立一条完整的服务端出站请求安全链路。对代码审计来说,重点关注用户可控 URL 是否进入网络请求 API;对修复来说,优先考虑允许列表、解析后 IP 校验、重定向逐跳校验、超时和响应大小限制。

实际项目中,建议把这套逻辑沉淀成统一组件,禁止业务代码直接使用裸 HTTP 客户端访问用户输入的 URL。这样既能减少重复实现,也能避免后续新增功能时再次引入同类问题。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…