背景
很多业务为了避免前端直接加载第三方图片,会做一个“图片代理”或“远程图片转存”服务:用户提交一个图片 URL,后端去拉取资源,校验类型后存储到对象存储或返回给前端。这类功能看起来很普通,但也是 SSRF 问题高发点。
最近在做一轮代码审计时,遇到一个典型场景:接口只判断了 URL 是否以 或 开头,然后直接由服务端发起请求。业务本意是转存头像和文章配图,但如果缺少边界控制,就可能访问到内网地址、云厂商元数据地址或本机管理端口。
本文只讨论防护视角下的排查方法、风险点和加固方案,不提供针对真实目标的利用步骤。
问题场景
简化后的接口逻辑大概如下:
POST /api/image/fetch
Content-Type: application/json
{
"url": "
}
后端代码逻辑类似:
public String fetchImage(String url) {
if (!url.startsWith(" && !url.startsWith(" {
throw new IllegalArgumentException("invalid url");
}
Response response = httpClient.get(url);
if (!response.contentType().startsWith("image/")) {
throw new IllegalArgumentException("not image");
}
return storage.save(response.body());
}
这段逻辑的主要问题不在“能不能访问外网图片”,而在“服务端请求的目标是否可控”。如果没有限制解析后的 IP、重定向目标、协议、端口和响应体大小,就容易形成 SSRF 风险。
技术分析
SSRF 的本质是攻击者控制服务端去发起网络请求。图片代理类接口常见风险点包括:
- 只校验字符串前缀:仅判断
不够,URL 解析存在编码、用户名密码段、大小写、特殊字符等差异。 - 未校验解析后的 IP:域名可能解析到内网 IP、本机 IP、链路本地地址或云元数据地址。
- 未处理 DNS Rebinding:第一次解析是公网 IP,请求建立前或重定向后变成内网 IP。
- 允许自动重定向:初始 URL 合法,但 302 跳转到不应访问的地址。
- 未限制端口:业务只需要 80/443,但代码允许访问任意端口。
- 未限制响应大小和超时:可能被用于拖垮线程池、连接池或磁盘。
- 只依赖 Content-Type:响应头可伪造,应结合文件头、大小、解码能力做校验。
审计思路
排查这类问题时,我一般按调用链而不是单个接口看:
- 入口:哪些接口接收 URL、图片地址、回调地址、Webhook 地址、文件地址。
- 解析:是否使用标准 URL/URI 解析器,是否存在手写字符串判断。
- 请求:HTTP 客户端是否自动跟随重定向,是否支持非 HTTP 协议。
- 网络:请求是否走固定出口,是否有 egress 防火墙策略。
- 校验:是否校验最终连接 IP,而不仅是用户输入的 Host。
- 资源:是否限制超时、大小、MIME、图片尺寸、并发。
可以在代码仓库中先做关键词搜索:
grep -R "httpClient\|RestTemplate\|OkHttp\|URL(" -n src/
grep -R "callback\|webhook\|imageUrl\|avatar\|fetch\|download" -n src/
如果是 Java 项目,重点看 RestTemplate、OkHttpClient、HttpClient、URLConnection 的使用位置;如果是 Go 项目,重点看 http.Get、http.Client;如果是 Node.js,重点看 axios、node-fetch、request。
关键加固步骤
1. 使用标准解析器解析 URL
不要用字符串拼接和简单正则替代 URL 解析。解析后至少检查协议、Host、端口:
URI uri = URI.create(inputUrl);
String scheme = uri.getScheme();
String host = uri.getHost();
int port = uri.getPort();
if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) {
throw new IllegalArgumentException("unsupported scheme");
}
if (host == null || host.isBlank()) {
throw new IllegalArgumentException("invalid host");
}
if (port != -1 && port != 80 && port != 443) {
throw new IllegalArgumentException("unsupported port");
}
注意:URL 解析只解决格式问题,不等于安全。真正关键的是后续的 IP 校验和网络出口限制。
2. 校验域名解析后的 IP
对于用户可控 Host,需要解析后判断是否落在禁止访问的地址范围内。常见禁止范围包括:
- 127.0.0.0/8,本机回环地址
- 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,RFC1918 内网地址
- 169.254.0.0/16,链路本地地址
- ::1/128,IPv6 回环地址
- fc00::/7,IPv6 Unique Local Address
- fe80::/10,IPv6 链路本地地址
伪代码如下:
List<InetAddress> addresses = Arrays.asList(InetAddress.getAllByName(host));
for (InetAddress address : addresses) {
if (isPrivateOrLocalAddress(address)) {
throw new IllegalArgumentException("forbidden address");
}
}
这里有两个容易踩坑的点:
- 一个域名可能返回多个 A/AAAA 记录,不能只检查第一个。
- IPv6 也要检查,不能只处理 IPv4。
3. 禁止或严格处理重定向
图片下载功能通常不需要复杂跳转。建议默认关闭自动重定向,若业务必须支持,则每一次跳转都重新做 URL 解析、协议校验、端口校验和 IP 校验。
OkHttpClient client = new OkHttpClient.Builder()
.followRedirects(false)
.followSslRedirects(false)
.connectTimeout(Duration.ofSeconds(3))
.readTimeout(Duration.ofSeconds(5))
.callTimeout(Duration.ofSeconds(8))
.build();
如果手动处理 301/302/307/308,建议设置最大跳转次数,例如不超过 3 次,避免循环跳转消耗资源。
4. 限制请求大小、超时和响应类型
只校验 Content-Type 不够,建议组合使用以下限制:
- 连接超时、读取超时、总超时都要设置。
- 限制响应体最大大小,例如头像 2MB、文章配图 10MB。
- 校验文件魔数,不要完全信任响应头。
- 读取图片尺寸,限制像素总量,避免解码炸弹。
- 流式读取,不要无上限读入内存。
long maxBytes = 2 * 1024 * 1024;
try (InputStream in = response.body().byteStream()) {
LimitedInputStream limited = new LimitedInputStream(in, maxBytes);
byte[] header = limited.peek(16);
if (!isSupportedImageMagic(header)) {
throw new IllegalArgumentException("unsupported image format");
}
storage.save(limited);
}
5. 在网络层做兜底
应用层校验很重要,但不能只依赖应用层。更稳妥的做法是给这类出网请求单独走受控出口,并在网络层限制访问范围。
- 容器或主机层禁止访问内网敏感网段。
- 禁止访问云元数据地址。
- 只允许访问 80/443 出口。
- 为图片代理服务使用独立的最小权限运行环境。
例如在测试环境可用防火墙策略验证出网边界是否生效:
# 示例:仅用于说明网络出口收敛思路,实际规则需结合环境评估
iptables -A OUTPUT -d 127.0.0.0/8 -j REJECT
iptables -A OUTPUT -d 10.0.0.0/8 -j REJECT
iptables -A OUTPUT -d 172.16.0.0/12 -j REJECT
iptables -A OUTPUT -d 192.168.0.0/16 -j REJECT
iptables -A OUTPUT -d 169.254.0.0/16 -j REJECT
在 Kubernetes 环境,可以用 NetworkPolicy 或云厂商安全组做类似限制。重点是:即使代码漏了某个分支,网络层也应尽量阻断不该访问的目标。
验证清单
| 检查项 | 建议结果 |
|---|---|
| 协议限制 | 仅允许 http/https |
| 端口限制 | 默认仅允许 80/443 |
| 内网地址 | 解析后拦截私有地址、本机地址、链路本地地址 |
| IPv6 | 纳入校验,不忽略 AAAA 记录 |
| 重定向 | 默认关闭;如开启则逐跳校验 |
| 超时 | 设置连接、读取、总超时 |
| 响应大小 | 按业务场景限制最大字节数 |
| 文件类型 | 响应头、文件头、解码校验组合判断 |
| 网络出口 | 阻断内网、元数据地址和非必要端口 |
| 日志审计 | 记录请求域名、解析 IP、状态码、大小、耗时 |
日志与监控建议
这类接口的日志不要只记用户传入的 URL,建议记录解析后的关键字段,便于后续排查异常:
- 请求来源用户或业务方标识
- 原始 URL 的脱敏版本
- 解析出的 scheme、host、port
- DNS 解析得到的 IP 列表
- 最终请求状态码、响应大小、耗时
- 被拦截原因,例如 forbidden_address、unsupported_scheme、too_large
监控上可以关注几个指标:
- 单用户短时间内大量请求不同域名
- 访问失败率突然升高
- 被安全策略拦截的次数异常增加
- 响应耗时和下载大小异常
几个实践中的注意点
- 不要维护过于宽松的域名白名单:如果业务能收敛到可信图片源,白名单是好方案;如果白名单允许用户自由配置,仍然要做 IP 和重定向校验。
- 不要只在前端限制:前端校验可以提升体验,但安全边界必须在后端。
- 不要复用高权限网络环境:图片代理服务不应和管理后台、内网运维工具处在同一访问权限下。
- 注意缓存污染:转存后的文件名、扩展名、Content-Type 应由服务端重新生成和判断,不要完全沿用远端返回值。
- 处理失败要克制:错误信息不要回显过多内部网络细节,例如具体内网 IP、连接栈信息。
总结
图片代理、远程转存、Webhook 回调、URL 预览等功能,本质上都是“服务端代用户访问一个地址”。只要目标地址可控,就需要按 SSRF 风险处理。
比较稳妥的防护思路是三层组合:应用层严格解析和校验,HTTP 客户端限制重定向、超时和大小,网络层限制不必要的出口访问。单点措施容易遗漏,组合措施才能覆盖编码绕过、DNS 变化、重定向和资源消耗等问题。
如果团队里有类似功能,建议优先拉一遍调用链,重点看是否存在“用户传 URL,服务端直接请求”的代码路径。很多 SSRF 问题并不复杂,但一旦落在云环境或内网权限较大的服务上,影响会被放大。
推荐意见