做图片相关的业务,上传这一环几乎绕不过去。很多团队最初的做法是:前端把图片 POST 给自己的后端,后端接住、校验、再转手上传到 OSS 或 S3。这套链路能跑,但图片一多就露怯------服务端白白吃掉两份带宽(用户传进来一份、再传出去一份),大图还占内存、拖慢线程。真正省事的做法是让前端直接把文件 PUT 到对象存储,服务端只负责"签个字",不碰文件字节。
这就是"前端直传"。听起来简单,落地时坑不少:凭证怎么发才安全、CORS 怎么配、分片怎么直传、回调能不能被伪造。下面按"为什么直传 → 预签名 URL → STS 临时凭证 → CORS → 表单直传 → 分片直传 → 回调校验 → 安全边界"的顺序,把整条链路讲透,每一步都给能跑的代码。
一、为什么要直传,账要算清
传统"过服务端"的链路是:浏览器 → 你的后端 → 对象存储。前端直传把它改成:浏览器 → 对象存储,你的后端只在旁边发凭证、收回调。
直传的收益很直接:服务端不再承担文件流量,带宽和内存压力都甩给了对象存储;上传速度也快,因为少了一次中转跳。代价是复杂度上移------你不能再在后端那道关口顺手做校验(文件类型、大小、内容审核),这些要么放到凭证策略里提前约束,要么放到上传完成后的回调里补做。
核心矛盾就一句话:直传把"控制点"从"文件流经服务端"挪走了,你必须用别的手段(凭证策略 + 回调)把控制点补回来。这也是后面所有安全设计的出发点。
二、方案一:后端签发预签名 URL
预签名 URL(Presigned URL)是理解成本最低的方案。原理是:后端用自己的密钥,对"某个对象的 PUT 操作 + 有效期"算一个签名,拼成一个带签名参数的临时 URL。前端拿着这个 URL 直接 PUT 文件,对象存储验签通过就收下。密钥全程不出后端,前端拿到的只是一次性的、限定对象和时限的授权。
后端签发(以 S3 为例,OSS 的 oss2 SDK 有对应的 sign_url):
python
import boto3
from botocore.config import Config
# 客户端只在服务端初始化,密钥来自环境变量,绝不下发前端
s3 = boto3.client(
"s3",
region_name="ap-northeast-1",
config=Config(signature_version="s3v4"),
)
def create_presigned_put(object_key: str, content_type: str) -> str:
# 关键:把 ContentType 也纳入签名,前端 PUT 时必须带一致的 Content-Type,
# 否则验签失败------这既是约束也是防篡改
return s3.generate_presigned_url(
ClientMethod="put_object",
Params={
"Bucket": "my-image-bucket",
"Key": object_key, # 对象路径由后端生成,别让前端随意指定
"ContentType": content_type,
},
ExpiresIn=300, # 5 分钟有效期,够传一张图就行
)
这里有个容易被忽略的安全点:对象 key 一定要后端生成 (比如 uploads/{user_id}/{uuid}.jpg),不要直接用前端传来的文件名。否则用户可以指定 key 覆盖别人的文件,或者往任意路径写数据。
前端拿到 URL 后直接 PUT,用 XMLHttpRequest 能顺带拿到进度:
js
// 前端直传:先问后端要签名 URL,再 PUT 上去
async function uploadByPresigned(file) {
// 1. 后端根据登录态决定 key,前端只报文件类型
const { url, key } = await fetch("/api/upload/presign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: file.type }),
}).then((r) => r.json());
// 2. 用 PUT 把文件字节直接送进对象存储
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
// Content-Type 必须和签名时一致,否则对象存储返回 403
xhr.setRequestHeader("Content-Type", file.type);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
console.log(`上传进度 ${((e.loaded / e.total) * 100).toFixed(1)}%`);
}
};
xhr.onload = () => (xhr.status < 300 ? resolve() : reject(xhr.status));
xhr.onerror = () => reject(new Error("network error"));
xhr.send(file); // 直接发 File 对象,浏览器不做额外封装
});
return key; // 拿到 key 回报后端落库
}
预签名 URL 的短板也明显:一个 URL 只授权一次 PUT。要传很多文件、或者要用分片,就得签很多个 URL,来回请求太碎。这时候更适合 STS。
三、方案二:STS 临时凭证
STS(Security Token Service)发的是一套临时的 AccessKeyId / AccessKeySecret / SecurityToken,带有效期,还能通过 Policy 精确限定权限------比如"只允许往 uploads/{user_id}/ 这个前缀写、只允许 PutObject、一小时内有效"。前端拿到这套临时凭证,就能用对象存储的官方 SDK 自由地传多个文件、走分片,不用每次都回后端签名。
后端换取 STS(以阿里云为例):
python
from alibabacloud_sts20150401.client import Client as StsClient
from alibabacloud_sts20150401 import models
from alibabacloud_tea_openapi import models as open_api_models
import json
def fetch_sts_token(user_id: str) -> dict:
config = open_api_models.Config(
access_key_id="<主账号 AK>",
access_key_secret="<主账号 SK>",
endpoint="sts.cn-hangzhou.aliyuncs.com",
)
client = StsClient(config)
# 用 Policy 把权限收窄到"这个用户自己的目录、只能上传"
policy = {
"Version": "1",
"Statement": [{
"Effect": "Allow",
"Action": ["oss:PutObject"], # 只给上传,不给读、不给删
"Resource": [f"acs:oss:*:*:my-image-bucket/uploads/{user_id}/*"],
}],
}
req = models.AssumeRoleRequest(
role_arn="acs:ram::<账号id>:role/oss-upload",
role_session_name=f"upload-{user_id}",
policy=json.dumps(policy),
duration_seconds=3600, # 最长 1 小时,别开太长
)
resp = client.assume_role(req).body.credentials
return {
"accessKeyId": resp.access_key_id,
"accessKeySecret": resp.access_key_secret,
"stsToken": resp.security_token,
"expiration": resp.expiration,
}
前端用临时凭证 + 官方 SDK 上传(ali-oss):
js
import OSS from "ali-oss";
async function uploadBySts(file, objectKey) {
const cred = await fetch("/api/upload/sts").then((r) => r.json());
const client = new OSS({
region: "oss-cn-hangzhou",
bucket: "my-image-bucket",
accessKeyId: cred.accessKeyId,
accessKeySecret: cred.accessKeySecret,
stsToken: cred.stsToken, // 临时凭证的三件套缺一不可
refreshSTSToken: async () => { // 凭证到期自动续,长传必备
const c = await fetch("/api/upload/sts").then((r) => r.json());
return { accessKeyId: c.accessKeyId, accessKeySecret: c.accessKeySecret, stsToken: c.stsToken };
},
});
// SDK 内部会按文件大小自动决定是否走分片
const res = await client.multipartUpload(objectKey, file, {
progress: (p) => console.log(`进度 ${(p * 100).toFixed(1)}%`),
parallel: 3, // 并发分片数
partSize: 1024 * 1024, // 每片 1MB
});
return res.name;
}
STS 比预签名灵活,但临时凭证一旦下发到浏览器就是明文的,用户在控制台能看到。所以 Policy 一定要收窄------权限越小,泄露的代价越小。别图省事发个全桶读写的凭证。
四、CORS:直传绕不开的头一个坑
前端直传是浏览器直接向对象存储的域名发请求,属于跨域。对象存储桶如果没配 CORS,浏览器会在预检(OPTIONS)阶段就把请求拦下,控制台报一堆 CORS 错误,很多人头一回直传就卡在这。
桶的 CORS 规则要允许你的站点来源、允许 PUT/POST 方法,还要把带进度和 ETag 需要的响应头暴露出来:
json
{
"AllowedOrigins": ["https://your-site.com"],
"AllowedMethods": ["PUT", "POST", "GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag", "x-oss-request-id"],
"MaxAgeSeconds": 600
}
ExposeHeaders 里的 ETag 尤其关键------分片上传合并时要按 part 的 ETag 拼清单,不暴露的话前端 SDK 读不到,合并会失败。AllowedOrigins 别偷懒写 *,虽然直传本身靠签名授权、* 不至于直接出事,但收窄来源是基本卫生。
五、表单直传:用 Post Policy 卡住大小和类型
预签名 PUT 有个尴尬:它没法在服务端约束"文件不能超过 5MB""只能是图片"。用户拿到 URL,塞个 100MB 的文件或者一个 exe 进去,PUT 一样成功。要在凭证层面就卡住这些,得用 POST 表单直传 + Post Policy。
Post Policy 是一段 JSON,声明了这次上传允许的条件(大小范围、key 前缀、Content-Type),后端用密钥签名后连同表单字段一起发给前端。对象存储收到 POST 表单时会按 Policy 校验,不满足直接拒收:
python
import base64, json, hmac, hashlib, datetime
def build_post_policy(user_id: str, sk: str) -> dict:
expire = (datetime.datetime.utcnow() + datetime.timedelta(minutes=5))
policy = {
"expiration": expire.strftime("%Y-%m-%dT%H:%M:%S.000Z"),
"conditions": [
["content-length-range", 0, 5 * 1024 * 1024], # 限制 ≤5MB
["starts-with", "$key", f"uploads/{user_id}/"], # 限制目录
["starts-with", "$Content-Type", "image/"], # 限制图片类型
],
}
encoded = base64.b64encode(json.dumps(policy).encode()).decode()
signature = base64.b64encode(
hmac.new(sk.encode(), encoded.encode(), hashlib.sha1).digest()
).decode()
return {"policy": encoded, "signature": signature}
前端把这些字段拼进 FormData,文件字段一定要放最后:
js
async function uploadByPost(file, key) {
const { policy, signature, ossAccessKeyId, host } =
await fetch("/api/upload/post-policy").then((r) => r.json());
const form = new FormData();
form.append("key", key);
form.append("OSSAccessKeyId", ossAccessKeyId);
form.append("policy", policy);
form.append("Signature", signature);
form.append("Content-Type", file.type);
form.append("file", file); // file 必须是表单最后一个字段,这是硬性约定
const res = await fetch(host, { method: "POST", body: form });
if (res.status >= 300) throw new Error("upload rejected by policy");
return key;
}
Content-Type 的校验只是拦"声明",不是查文件真实内容------用户可以给一个 exe 声明成 image/png。真正的内容判断还得靠上传后的回调去做。
六、分片直传:大图别用整包 PUT
前面 STS 那段的 multipartUpload 已经把分片直传封装好了,这里补一下它和"传自己服务器再分片"的区别:分片直传是直接对对象存储发起 InitiateMultipartUpload,把每一片 UploadPart 到存储、最后 CompleteMultipartUpload 合并,整个过程不经过你的后端。SDK 会自动管理 uploadId、part 编号、ETag 收集和最终合并,断点续传也是基于 uploadId 恢复的。
自己手写的话,要点是:每片 file.slice(start, end) 后独立 PUT(或用签名过的 part URL),保存每片返回的 ETag,全部传完再发合并请求带上 {partNumber, etag} 列表。手写容易漏的是合并的原子性和脏分片清理------传到一半放弃的 uploadId 会在桶里留下碎片,占空间还收费,记得配一条生命周期规则自动清理未完成的分片。
七、上传回调与安全校验:回调是能被伪造的
直传绕过了服务端,你的后端怎么知道"文件传成功了、该落库了"?靠回调。对象存储在收到文件后,会主动 POST 一个回调到你配置的后端地址,带上 key、size、mime 等信息。
这里是直传安全的重灾区:回调地址是公开的 HTTP 接口,任何人都能伪造一个 POST 打过来,声称"某文件已上传",骗你的后端落一条不存在的记录。所以回调必须验签。对象存储会用它的私钥对回调内容签名,把签名和公钥地址放在请求头,后端用公钥验证:
python
import base64, requests
from urllib.parse import unquote
from Crypto.Signature import PKCS1_v1_5
from Crypto.Hash import MD5
from Crypto.PublicKey import RSA
def verify_oss_callback(headers: dict, body: bytes, request_uri: str) -> bool:
# 1. 公钥地址必须校验是不是对象存储官方域名,否则攻击者能塞自己的公钥
pub_key_url = base64.b64decode(headers["x-oss-pub-key-url"]).decode()
if not pub_key_url.startswith("https://gosspublic.alicdn.com/"):
return False
pub_key = requests.get(pub_key_url, timeout=3).text
signature = base64.b64decode(headers["authorization"])
# 2. 按官方规则拼待签名串:解码后的 path + '\n' + body
auth_str = unquote(request_uri) + "\n" + body.decode()
verifier = PKCS1_v1_5.new(RSA.importKey(pub_key))
digest = MD5.new(auth_str.encode())
return verifier.verify(digest, signature)
验签通过只说明"这个回调确实来自对象存储",还不够。稳妥的做法是验签之后再主动 HeadObject 回查一次,确认对象真实存在、大小和 mime 与回调声明的一致,再落库。双重确认能挡掉大部分伪造和篡改。
如果你没配对象存储自带的回调,而是让前端上传成功后自己调后端接口报告"我传完了"------那这个"报告"更是可以随便编的,后端必须以 HeadObject 回查为准,绝不能信前端一面之词。
八、诚实的安全边界
直传方便,但它把安全责任摊薄到了好几个环节,哪一环松了都会漏:
- 凭证泄露:STS 临时凭证是明文下发到浏览器的,Policy 没收窄就等于把桶的权限半公开。永远按最小权限发,限定前缀、动作、时长。
- 类型和大小 :Content-Type 只是声明,Post Policy 的
content-length-range能卡大小,但真实内容(是不是图、有没有嵌恶意载荷)得靠回调后异步做内容检测。 - 回调可伪造:回调接口是公网开放的,必须验签 + HeadObject 回查双保险,别裸信回调 body。
- 对象 key 越权:key 由后端按登录态生成,别用前端文件名,否则有覆盖和越权写风险。
- 孤儿分片:分片直传中断会留碎片,配生命周期规则定期清。
文件传上去、落库之后,通常还有一轮加工要做------压缩、格式统一成 WebP、生成缩略图。这类活儿在线工具能先顶一阵,需求不同各有取舍:squoosh.app 适合逐张手动调质量和格式,tinypng.com 对 PNG/JPG 批量压缩省心,tudingai.cn 偏在线修图和格式处理,cloudconvert.com 走批量格式转换;量大且要进流程的话,再考虑用 sharp、Pillow 之类在服务端批处理。
小结
前端直传的本质,是把"文件流经服务端"这个天然的控制点拿掉,换取带宽和性能,再用凭证策略和回调把控制点补回来。预签名 URL 胜在简单,适合零散的单文件上传;STS 临时凭证胜在灵活,适合多文件和分片;Post Policy 能在凭证层卡住大小和类型;而回调验签 + HeadObject 回查,是整套方案里最不能省的一环。把这几块拼齐,直传才既省事又不留后门。