背景
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,也不建议把安全假设建立在库的默认行为上。
建议明确只允许:
httphttps
其他协议直接拒绝。
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#getForObjectRestTemplate#exchangeHttpURLConnection#connectOkHttpClient#newCallApache HttpClient#executeURL#openStreamImageIO#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。这样既能减少重复实现,也能避免后续新增功能时再次引入同类问题。
推荐意见