背景
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,关闭或受控处理重定向,限制响应大小和类型;同时在网络层限制出网范围。单靠黑名单字符串判断基本不可靠,统一封装请求组件和出网策略,才是长期维护成本较低的做法。
推荐意见