1. 前言

最近在做的一个项目中需要提供用户图片上传的接口,包括头像、文章配图等内容。但是服务器的公网带宽有限,如果所有的图片都经过后端中转并最终落库OSS中,可能几个用户同时上传图片就可以把接口带宽打满。于是我最终决定由用户浏览器直接上传图片至OSS中的方案,但是授予用户直接上传图片的方案有很大的风险,如果没有做好对应的防护措施,可能会出现:

  1. 用户上传带有恶意脚本的图片,导致安全不受控
  2. 用户将OSS当作图床,加剧账单消耗
  3. 其他可能出现的安全问题

因此,一个安全的浏览器直传方案必不可少。

2. 方案设计

2.1 整体直传思路

整个上传被拆成了五步,浏览器、业务 API、OSS 和图片处理 Worker 分工合作:

text
浏览器 ──① 申请授权──▶ 业务 API ──② 返回签名表单──▶ 浏览器
浏览器 ──③ PostObject 直传──▶ 临时桶 media-ingest
浏览器 ──④ complete(uploadId)──▶ 业务 API ──入队──▶ Worker
Worker ──⑤ 内网读取、核验、重编码──▶ 正式桶 media-private ──▶ READY

这里有两个桶:

桶内容谁能访问
media-ingest用户刚传上来的原始文件,视为不可信上传授权只允许创建;Worker 读;清理任务删
media-private经过重编码的正式图片只有服务端;浏览器拿不到任何 OSS 读凭证

2.2 申请授权

首先,浏览器会尝试调用业务API,在此时服务端会先做一轮检查,用来检查图片大小、图片类型和SHA值。

java
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 的条件之中:

字段取值作用
keyingest/{小时}/{uploadId}/source精确匹配,不用 starts-with,不用 ${filename}
content-length-range[1, 申请时声明的大小]传空文件或超过声明大小都会被 OSS 拒绝
x-oss-forbid-overwritetrue同一个 key 只能成功写入一次
x-oss-object-aclprivate不允许把对象写成公共读
x-oss-storage-classStandard防止被切换成低频或归档类型
x-oss-server-side-encryptionAES256固定服务端加密
Content-Type申请时声明的 image/jpeg 或 image/png浏览器不能改也不能省略
x-oss-meta-upload-iduploadId只用来关联会话,不当作用户身份
success_action_status201不允许自定义 redirect 或回调
expiration签发时间 + 120 秒由服务端决定,客户端不能延长

通过限制这些字段,用户将没有选择的余地,只能在两分钟内往一个指定的 key 写一个不超过声明大小的私有对象,并且只能成功一次。

2.4 令牌签发后端代码示例

签名部分用的是 OSS 的 V4 签名,核心就是把固定字段整理成 conditions,再用派生密钥做一次 HMAC:

java
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:

java
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。

java
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 和扩展名都只是「声明」,不能证明文件内容。真正的判定在解码阶段:

java
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:

java
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 等数据也可以释放,无需继续维护。

至此,一个安全的文件上传方案就完成了~下一文章将会讲解「安全的文件分发方案」,敬请期待~

这篇文章带给你什么?

无需登录,点一个表情告诉我。