跳转到帖子

背景

SSRF(Server-Side Request Forgery)在实际项目里并不算新问题,但它经常藏在一些看起来“业务合理”的功能里,例如图片抓取、URL 预览、Webhook 校验、文件转存、在线导入配置等。很多团队在修复时只做了简单的黑名单判断,比如禁止 127.0.0.1,结果遇到 DNS 解析、IPv6、重定向、云厂商元数据地址等场景仍然会失效。

这篇文章整理一次偏防守视角的 Java/Spring 项目代码审计思路,重点不是讲攻击利用,而是说明如何在代码层面识别 SSRF 风险、如何验证风险边界,以及如何做相对稳妥的收敛。

典型场景

审计中遇到的功能大致如下:用户提交一个图片 URL,服务端下载图片并上传到对象存储,前端只保存最终的存储地址。这类功能很常见,问题通常出在服务端直接信任了用户传入的 URL。

POST /api/image/fetch
Content-Type: application/json

{
  "url": "
}

对应代码可能类似下面这样:

public String fetchImage(String url) throws IOException {
    URL target = new URL(url);
    HttpURLConnection conn = (HttpURLConnection) target.openConnection();
    conn.setConnectTimeout(3000);
    conn.setReadTimeout(5000);

    try (InputStream in = conn.getInputStream()) {
        return objectStorage.upload(in);
    }
}

这段代码业务上能跑通,但安全边界几乎没有:协议未限制、目标地址未限制、重定向未处理、响应大小未限制、Content-Type 未校验,最终会把服务端变成一个“代请求器”。

审计时优先关注的代码特征

代码审计时,我一般会先全局检索以下关键调用,定位所有“服务端主动向外请求”的位置。

new URL(
URI.create(
RestTemplate
WebClient
HttpClient
OkHttpClient
URLConnection
Jsoup.connect
ImageIO.read
SAXReader.read
DocumentBuilder.parse

需要注意的是,SSRF 不一定只出现在 HTTP 客户端里。XML 解析、图片处理、文档转换、URL 预览组件都可能间接触发外部请求。审计时建议结合数据流看:用户输入是否进入 URL/URI 构造、请求目标是否由用户控制、请求前是否有可信校验、请求中是否允许重定向。

风险点拆解

1. 只校验字符串前缀是不够的

常见但不可靠的写法:

if (!url.startsWith(" {
    throw new IllegalArgumentException("invalid url");
}

这只能限制协议字符串,并不能判断最终连接到哪里。URL 中可能包含编码、用户信息、端口、重定向等因素。更稳妥的做法是使用 URI/URL 标准解析后再判断 scheme、host、port,并在 DNS 解析后校验 IP 范围。

2. 仅禁止 localhost 不够

很多修复只拦截 localhost、127.0.0.1,但内网地址范围远不止这些。至少需要关注:

  • IPv4 私有地址:10.0.0.0/8、172.16.0.0/12、192.168.0.0/16
  • 本地回环:127.0.0.0/8
  • 链路本地:169.254.0.0/16
  • IPv6 回环与本地地址:::1、fe80::/10、fc00::/7
  • 云环境元数据地址,例如 169.254.169.254

3. 重定向会绕开第一次校验

即使初始 URL 是外部可信域名,如果 HTTP 客户端自动跟随 30x 跳转,最终请求目标也可能变化。因此校验不能只发生在第一次请求前。建议关闭自动重定向,手动处理每一次 Location,并对每一次跳转后的目标重新做完整校验。

HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setInstanceFollowRedirects(false);

4. DNS 解析与连接之间存在竞态

如果只在请求前解析一次域名并判断 IP,随后再交给 HTTP 客户端自行解析,存在 DNS Rebinding 风险。比较稳妥的方式是:解析后拿到 IP,校验 IP 是否允许,再使用受控的连接方式,确保实际连接目标与校验结果一致。不同语言和 HTTP 客户端实现差异较大,不能简单依赖“校验 host 字符串”。

一个相对可落地的防护流程

以下流程适合多数“用户提交 URL,服务端拉取资源”的业务:

  • 只允许 http/https,拒绝 file、gopher、ftp 等非必要协议。
  • 解析 URI,规范化 host,禁止空 host、异常端口、用户名密码形式 URL。
  • DNS 解析 host,校验解析出的所有 IP,不允许内网、回环、链路本地、保留地址。
  • 关闭自动重定向;如业务必须支持跳转,限制跳转次数,并对每次 Location 重新执行完整校验。
  • 限制响应大小,避免被大文件拖垮内存或磁盘。
  • 限制超时时间,避免长连接占用线程。
  • 校验 Content-Type 和文件魔数,不只看响应头。
  • 服务端出网做网络层限制,应用层校验不能替代 egress 策略。

示例:URL 安全校验的基本骨架

下面示例只展示核心思路,生产环境建议补充更完整的 CIDR 判断、日志记录和单元测试。

public final class UrlGuard {

    private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");

    public static URI parseAndCheck(String input) throws Exception {
        URI uri = new URI(input).normalize();

        String scheme = uri.getScheme();
        if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase())) {
            throw new IllegalArgumentException("unsupported scheme");
        }

        if (uri.getUserInfo() != null) {
            throw new IllegalArgumentException("userinfo is not allowed");
        }

        String host = uri.getHost();
        if (host == null || host.isBlank()) {
            throw new IllegalArgumentException("empty host");
        }

        int port = uri.getPort();
        if (port != -1 && port != 80 && port != 443) {
            throw new IllegalArgumentException("unsupported port");
        }

        InetAddress[] addresses = InetAddress.getAllByName(host);
        for (InetAddress address : addresses) {
            if (!isPublicAddress(address)) {
                throw new IllegalArgumentException("private or reserved address is not allowed");
            }
        }

        return uri;
    }

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

这里要强调一点:isSiteLocalAddress() 并不能覆盖所有保留地址,尤其是云元数据地址、IPv6 ULA 等场景。实际项目建议维护明确的 CIDR 拦截表,或者使用成熟网络库做 IP 范围判断。

示例:限制响应大小

不少 SSRF 风险即便无法访问敏感内网,也可能被滥用为资源消耗点。拉取图片、文件转存这类功能一定要限制响应大小。

private static final long MAX_BYTES = 5L * 1024 * 1024;

public static void copyLimited(InputStream in, OutputStream out) throws IOException {
    byte[] buffer = new byte[8192];
    long total = 0;
    int n;
    while ((n = in.read(buffer)) != -1) {
        total += n;
        if (total > MAX_BYTES) {
            throw new IOException("response too large");
        }
        out.write(buffer, 0, n);
    }
}

网络层兜底

应用层校验有必要,但不应该是唯一防线。更可靠的方式是在运行环境限制服务端出网范围,尤其是容器和云环境。

例如在 Kubernetes 中,可以使用 NetworkPolicy 限制 Pod 出站访问,只允许访问必要的外部服务或代理出口。示例仅表达思路:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: restrict-egress
spec:
  podSelector:
    matchLabels:
      app: image-service
  policyTypes:
    - Egress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              name: egress-gateway
      ports:
        - protocol: TCP
          port: 8080

如果业务确实需要访问互联网,可以考虑让应用只访问统一出网代理,由代理做域名白名单、IP 范围限制、审计日志和速率控制。这样比每个业务各自实现一套校验更可控。

验证与回归测试建议

修复 SSRF 后,建议补充单元测试和集成测试。测试重点不是“能不能请求成功”,而是确认危险目标被拒绝、边界场景不会漏掉。

测试项预期结果
http/https 正常公网域名允许,受大小和类型限制
file、ftp 等协议拒绝
127.0.0.1、localhost拒绝
10.0.0.0/8、192.168.0.0/16拒绝
169.254.169.254拒绝
IPv6 回环与本地地址拒绝
30x 跳转到受限地址拒绝
超大响应体中断并记录

日志方面,建议记录被拒绝的原因、规范化后的 host、解析出的 IP、调用方用户或业务标识,但不要记录完整敏感请求头或凭据。

常见误区

  • 只做前端校验:服务端请求行为必须在服务端校验。
  • 只拦截 localhost:内网、链路本地、IPv6、云元数据地址都要覆盖。
  • 只看 Content-Type:响应头可控,文件类型还需要结合魔数校验。
  • 自动跟随重定向:每次跳转都要重新校验目标。
  • 把 SSRF 当成单点漏洞修:所有服务端拉取 URL 的功能都应统一治理。

总结

SSRF 的难点不在于某一行代码,而在于“服务端是否替用户发起了不受控请求”。代码审计时可以从 URL 构造、HTTP 客户端、文件解析组件入手,沿着用户输入到网络请求的数据流排查。

修复上建议采用组合策略:应用层严格校验 URL 和解析后的 IP,关闭或受控处理重定向,限制响应大小和类型;同时在网络层限制出网范围。单靠黑名单字符串判断基本不可靠,统一封装请求组件和出网策略,才是长期维护成本较低的做法。

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

0篇意见

推荐意见

没有意见。

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