1. 前言
最近在做的一个项目中需要提供用户图片上传的接口,包括头像、文章配图等内容。但是服务器的公网带宽有限,如果所有的图片都经过后端中转并最终落库OSS中,可能几个用户同时上传图片就可以把接口带宽打满。于是我最终决定由用户浏览器直接上传图片至OSS中的方案,但是授予用户直接上传图片的方案有很大的风险,如果没有做好对应的防护措施,可能会出现:
- 用户上传带有恶意脚本的图片,导致安全不受控
- 用户将OSS当作图床,加剧账单消耗
- 其他可能出现的安全问题
因此,一个安全的浏览器直传方案必不可少。
2. 方案设计
2.1 整体直传思路
整个上传被拆成了五步,浏览器、业务 API、OSS 和图片处理 Worker 分工合作:
浏览器 ──① 申请授权──▶ 业务 API ──② 返回签名表单──▶ 浏览器
浏览器 ──③ PostObject 直传──▶ 临时桶 media-ingest
浏览器 ──④ complete(uploadId)──▶ 业务 API ──入队──▶ Worker
Worker ──⑤ 内网读取、核验、重编码──▶ 正式桶 media-private ──▶ READY
这里有两个桶:
| 桶 | 内容 | 谁能访问 |
|---|---|---|
media-ingest | 用户刚传上来的原始文件,视为不可信 | 上传授权只允许创建;Worker 读;清理任务删 |
media-private | 经过重编码的正式图片 | 只有服务端;浏览器拿不到任何 OSS 读凭证 |
2.2 申请授权
首先,浏览器会尝试调用业务API,在此时服务端会先做一轮检查,用来检查图片大小、图片类型和SHA值。
MediaPurpose purpose = MediaPurpose.parse(request.purpose()); // 回报上传意图,用于权限校验
// 可以根据不同场景进行权限校验,代码略
if (size < 1 || size > purpose.maxInputBytes()) throw invalid("图片大小超过该用途限制");
String mime = normalizeMime(request.mime()); // 只接受 image/jpeg 与 image/png
String sha = normalizeSha(request.sha256()); // 必须登记文件的 SHA-256
其中,这里的申请用途purpose与sha值在上传后端之后会被记录,用于后期校验。由于不同文件sha的相同概率非常小,因此可以用来检验申请的图片是否与最终上传的图片一致,这样可以防止申请后图片被篡改。
在获取到授权之后,浏览器会把返回的字段原封不动放入表单中,并上传至OSS中。OSS 返回 201 只说明文件落到了临时桶,不代表这张图片可以用。浏览器接下来会告知服务端传输完毕,此时后端会开始进行最后校验(具体见2.5),前端可以通过轮询查看处理状态。
2.3 令牌签发逻辑
阿里云 OSS 的 PostObject 支持用一份 policy 描述「允许什么样的表单上传」,后端用自己的凭证对 policy 签名,浏览器把 policy、签名和文件一起 POST 到 OSS,OSS 逐条校验 policy 里的条件。这意味着权限的边界完全由后端决定。
在此方案中,可以将以下的内容写入 policy 的条件之中:
| 字段 | 取值 | 作用 |
|---|---|---|
key | ingest/{小时}/{uploadId}/source | 精确匹配,不用 starts-with,不用 ${filename} |
content-length-range | [1, 申请时声明的大小] | 传空文件或超过声明大小都会被 OSS 拒绝 |
x-oss-forbid-overwrite | true | 同一个 key 只能成功写入一次 |
x-oss-object-acl | private | 不允许把对象写成公共读 |
x-oss-storage-class | Standard | 防止被切换成低频或归档类型 |
x-oss-server-side-encryption | AES256 | 固定服务端加密 |
Content-Type | 申请时声明的 image/jpeg 或 image/png | 浏览器不能改也不能省略 |
x-oss-meta-upload-id | uploadId | 只用来关联会话,不当作用户身份 |
success_action_status | 201 | 不允许自定义 redirect 或回调 |
expiration | 签发时间 + 120 秒 | 由服务端决定,客户端不能延长 |
通过限制这些字段,用户将没有选择的余地,只能在两分钟内往一个指定的 key 写一个不超过声明大小的私有对象,并且只能成功一次。
2.4 令牌签发后端代码示例
签名部分用的是 OSS 的 V4 签名,核心就是把固定字段整理成 conditions,再用派生密钥做一次 HMAC:
Map<String, String> fixed = new LinkedHashMap<>();
fixed.put("key", upload.ingestObjectKey());
fixed.put("success_action_status", "201");
fixed.put("x-oss-signature-version", "OSS4-HMAC-SHA256");
fixed.put("x-oss-credential", credential);
fixed.put("x-oss-date", dateTime);
fixed.put("x-oss-forbid-overwrite", "true");
fixed.put("x-oss-object-acl", "private");
fixed.put("x-oss-storage-class", "Standard");
fixed.put("x-oss-server-side-encryption", "AES256");
// PostObject 把 Content-Type 当作表单字段,同样纳入签名
fixed.put("Content-Type", upload.declaredMime());
fixed.put("x-oss-meta-upload-id", upload.uploadId());
List<Object> conditions = new ArrayList<>();
conditions.add(Map.of("bucket", bucket));
fixed.forEach((name, value) -> {
if (!isV4AuthenticationField(name)) {
conditions.add(List.of("eq", "$" + name, value));
}
});
conditions.add(List.of("content-length-range", 1, upload.declaredSize()));
Map<String, Object> policy = new LinkedHashMap<>();
policy.put("expiration", Instant.ofEpochMilli(upload.policyExpiresAt()).toString());
policy.put("conditions", conditions);
派生密钥的过程和 OSS 文档一致,一共四层 HMAC:
byte[] dateKey = hmac(("aliyun_v4" + secret).getBytes(UTF_8), date);
byte[] regionKey = hmac(dateKey, region);
byte[] serviceKey = hmac(regionKey, "oss");
byte[] signingKey = hmac(serviceKey, "aliyun_v4_request");
String signature = hex(hmac(signingKey, base64Policy));
这里需要注意的是,后端用的是 ECS 实例角色的临时凭证,它会定期轮换。所以在一次签名里只读取一次凭证,避免 AccessKey ID 和 Secret 来自两次不同的轮换。浏览器能看到临时的 AccessKey ID 和 security token,这是 STS 正常工作所需,但是永远看不到 Secret,因此无法自行签出任何其他请求。
2.5 服务端核验与重编码
2.5.1 校验原始对象
Worker 通过 OSS 内网 Endpoint 读取临时桶里的对象,并检查真实大小、Content-Type、SHA-256。
MediaObjectStorage.ObjectInfo info = storage.headIngest(upload.ingestObjectKey());
if (info.contentLength() < 1 || info.contentLength() > upload.declaredSize()) throw invalid("上传图片大小不符");
if (info.contentType() != null && !info.contentType().equalsIgnoreCase(upload.declaredMime())) {
throw invalid("上传图片类型与授权不符");
}
byte[] raw = storage.readIngest(upload.ingestObjectKey(), upload.declaredSize());
if (!upload.clientSha256().equals(sha256(raw))) throw invalid("上传图片校验失败");
readIngest 读取时用的是 readNBytes(maxBytes + 1),多读一个字节用来判断对象是否超出了声明大小,即使 OSS 侧的限制被绕过,服务端也不会把超大文件读进内存。
2.5.2 校验文件类型
当然,MIME 和扩展名都只是「声明」,不能证明文件内容。真正的判定在解码阶段:
Iterator<ImageReader> readers = ImageIO.getImageReaders(imageInput);
if (!readers.hasNext()) throw invalid();
ImageReader reader = readers.next();
String format = reader.getFormatName();
if (!"JPEG".equalsIgnoreCase(format) && !"PNG".equalsIgnoreCase(format)) throw invalid();
int width = reader.getWidth(0), height = reader.getHeight(0);
if (width > 4096 || height > 4096 || (long) width * height > 16_000_000L) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "图片尺寸过大");
}
先读尺寸再解码,是因为有可能出现像素量很大但是文件大小很小的情况。另外针对于 PNG 的图片,还需要额外校验 acTL 这个动画控制块。如果出现的话就直接拒绝,其他类型也暂时不支持上传。
2.5.3 重编码并生成固定规格
通过检查的图片不会被原样保存,而是按用途缩放后重新编码成 JPEG:
List<Spec> specs = switch (purpose) {
case PRIVATE_PHOTO -> List.of(new Spec("main", 512));
case USER_AVATAR -> List.of(new Spec("main", 256), new Spec("thumb", 64));
case POST_IMAGE -> List.of(new Spec("main", 1600), new Spec("thumb", 320));
};
for (Spec spec : specs) {
BufferedImage resized = resize(original, width, height, spec.edge);
byte[] encoded = encodeJpeg(resized, purpose.maxOutputBytes() - total);
...
}
重编码这一步同时解决了三个问题:
| 问题 | 重编码之后 |
|---|---|
| 原图带 EXIF、GPS 等元数据 | 写入新图时不带任何元数据,自然被剥离 |
| 文件里藏了额外的字节或脚本 | 只有解码出来的像素会被保留 |
| 不同用户上传的尺寸千差万别 | 每种用途输出固定最长边,前端展示不需要再处理 |
encodeJpeg 会从 0.82 的质量开始逐级下调,直到输出总字节落在该用途的上限以内,压到最低仍然超限就拒绝。
2.5.4 写入正式桶
生成的变体被写进正式桶,key 的格式是 media/{用途}/{userId}/{assetId}/{变体名}.jpg,同样带 x-oss-forbid-overwrite,并且 ACL 固定为 private。只有当所有变体都写入成功、数据库事务提交之后,会话才会进入 READY 并返回 media:<assetId>。中途任何一步失败,已经写入的对象会被回滚删除。
2.5.5 数据清理
确认写入正式桶之后,需要删除 media-ingest 中的临时文件,并且之前的 mime 等数据也可以释放,无需继续维护。
至此,一个安全的文件上传方案就完成了~下一文章将会讲解「安全的文件分发方案」,敬请期待~
无需登录,点一个表情告诉我。