用户上传头像、合同 PDF 或 CSV 导入文件时,最容易出现这种现场:后端已经返回 Cloudflare R2 的 presigned URL,前端 fetch(url, { method: "PUT" }) 一跑,Chrome console 只丢一句 CORS error。你把同一个 URL 复制到 curl 里又能传,于是怀疑 SDK、R2 token、bucket 权限全都有问题。
我的默认判断是:浏览器里失败,先不要重签 URL,也不要先改 R2 API token。打开 DevTools 的 Network,找到失败的 OPTIONS 和 PUT,把 Origin、Method、Request Headers 三列抄下来。Cloudflare 文档说得很清楚:presigned URL 只处理临时授权;从浏览器跨源访问 R2,bucket 仍然需要 CORS policy。
浏览器 console 里哪句话说明是 CORS?
Chrome 常见提示会长这样:Access to fetch at ... from origin ... has been blocked by CORS policy。这个信号说明请求被浏览器拦下,不等于 R2 已经拒绝了对象写入。真正要看的是 Network 里的两条请求:
| Network 请求 | 你要看什么 | 读法 |
|---|---|---|
OPTIONS | Status、Origin、Access-Control-Request-Method、Access-Control-Request-Headers | 预检没过,后面的 PUT 通常不会真正发出 |
PUT | Status、Request Method、Content-Type、响应里的 XML 错误 | PUT 发出后 403,更多是签名、header 或过期时间问题 |
| Console error | blocked by CORS policy、preflight response | 只当入口线索,不当最终原因 |
如果 Network 里只有 OPTIONS 失败,优先改 bucket CORS;如果 OPTIONS 204 或 200 通过,PUT 返回 403,再回到签名参数、Content-Type 和 URL 过期时间。
bucket CORS 要和哪些字段完全对上?
R2 的 CORS policy 不是写一个 * 就完事。对用户上传来说,最少要把生产域名、本地开发端口、PUT 方法和前端实际发送的请求头写进去。
| 前端实际行为 | CORS 字段 | 常见错误 | 应该怎么写 |
|---|---|---|---|
https://app.example.com 发起上传 | AllowedOrigins | 写成 https://app.example.com/upload 或少了协议 | 写完整 origin:https://app.example.com |
http://localhost:3000 本地调试 | AllowedOrigins | 只写生产域名,忘了端口 | 单独加 http://localhost:3000 |
fetch(..., { method: "PUT" }) | AllowedMethods | 只允许 GET | 上传 URL 要允许 PUT |
前端设置 Content-Type: image/png | AllowedHeaders | 漏掉 Content-Type | 加 Content-Type |
| SDK 或前端发送校验头 | AllowedHeaders | 漏掉 x-amz-checksum-* 或自定义 metadata | 把 Network 里出现的 header 加进去 |
| 前端要读取对象 hash | ExposeHeaders | 上传成功但 JS 读不到 ETag | 加 ETag |
| 频繁上传小文件 | MaxAgeSeconds | 每次都重新预检 | 可设 3600,减少重复预检 |
Cloudflare 还提醒,只有带合法 Origin 的跨源请求才会返回 CORS 响应头。你用 curl 测时如果没加 Origin,看不到 Access-Control-* 头是正常的。
一个可直接改的 R2 CORS JSON 怎么写?
下面这份配置适合最常见的 SaaS 上传:生产前端、localhost、本地 PUT 上传、前端固定发送 Content-Type,上传后读取 ETag。如果你的前端还发送 Cache-Control、Content-MD5 或 x-amz-meta-*,要按 Network 面板里的实际 header 补进去。
[
{
"AllowedOrigins": [
"https://app.example.com",
"http://localhost:3000"
],
"AllowedMethods": [
"PUT",
"HEAD"
],
"AllowedHeaders": [
"Content-Type",
"Cache-Control",
"x-amz-checksum-sha256",
"x-amz-meta-upload-id"
],
"ExposeHeaders": [
"ETag"
],
"MaxAgeSeconds": 3600
}
]
Dashboard 路径是 R2 bucket -> Settings -> CORS Policy -> JSON。上面这个数组形态适合 Dashboard JSON。走 Wrangler CLI 时,Cloudflare 文档使用的是 rules 包裹的文件形态,可以另存为 cors-wrangler.json:
{
"rules": [
{
"allowed": {
"origins": [
"https://app.example.com",
"http://localhost:3000"
],
"methods": [
"PUT",
"HEAD"
],
"headers": [
"Content-Type",
"Cache-Control",
"x-amz-checksum-sha256",
"x-amz-meta-upload-id"
]
},
"exposeHeaders": [
"ETag"
],
"maxAgeSeconds": 3600
}
]
}
然后执行:
npx wrangler r2 bucket cors set my-bucket --file cors-wrangler.json
npx wrangler r2 bucket cors list my-bucket
注意两点。第一,AllowedOrigins 不要写路径,也不要多一个结尾斜杠。第二,Cloudflare 文档提到 CORS 规则传播少数情况下可能需要最多 30 秒;刚保存完别用旧 tab 里的缓存结果立刻下结论。
presigned URL 本身要怎样生成才不埋坑?
生成 presigned URL 时,方法、对象 key、过期时间和签名 headers 已经固定。R2 文档给的核心约束是:presigned URL 授权单个对象上的单个 S3 操作,过期时间可设 1 秒到 7 天,支持 GET、HEAD、PUT、DELETE,不支持 HTML form 的 POST multipart upload。
一个精简的 TypeScript 生成例子如下:
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const r2 = new S3Client({
region: "auto",
endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
export async function createUploadUrl(userId: string, fileName: string) {
const key = `uploads/${userId}/${crypto.randomUUID()}-${fileName}`;
return getSignedUrl(
r2,
new PutObjectCommand({
Bucket: "my-bucket",
Key: key,
ContentType: "image/png",
CacheControl: "private, max-age=0",
Metadata: {
"upload-id": crypto.randomUUID(),
},
}),
{ expiresIn: 600 },
);
}
这里的 ContentType、CacheControl、Metadata 会影响前端要发什么 header。前端如果把 image/png 改成浏览器自动推断的 application/octet-stream,可能不再是 CORS,而是签名校验失败。一个人维护上传链路时,最省时间的办法是让后端返回 url 的同时返回 headers,前端原样使用。
前端 PUT 请求应该怎么写才方便排错?
不要先上复杂的上传库。用一段可读的 fetch 把问题压小,确认成功后再接进 Dropzone、Uppy 或你的组件。
async function uploadToR2(file: File) {
const signRes = await fetch("/api/uploads/sign", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fileName: file.name,
contentType: "image/png",
}),
});
const { url, headers } = await signRes.json();
const uploadRes = await fetch(url, {
method: "PUT",
headers,
body: file,
});
if (!uploadRes.ok) {
const text = await uploadRes.text();
throw new Error(`R2 upload failed: ${uploadRes.status} ${text}`);
}
return {
etag: uploadRes.headers.get("ETag"),
};
}
调试时把 headers 打出来,但不要把完整 presigned URL 贴到工单、截图或公共 issue。Cloudflare 说明 presigned URL 要当 bearer token 看待,拿到 URL 的人可以在过期前执行被授权的操作。
什么时候其实不是 CORS,而是签名或域名错了?
有三个信号可以把问题从 CORS 分出去:
| 现象 | 更可能的原因 | 处理方式 |
|---|---|---|
OPTIONS 通过,PUT 返回 SignatureDoesNotMatch | 签名里的 method、key、Content-Type 或 header 和实际请求不同 | 对照 X-Amz-SignedHeaders 和前端请求头 |
PUT 返回 403,XML 提到 URL 过期 | X-Amz-Expires 超时,或客户端时间偏差太大 | 缩短签名到上传动作之间的等待,重新请求 URL |
| 用自定义域名拼 presigned URL | 入口域名错 | R2 presigned URLs 使用 S3 API domain,不用于 custom domains |
前端用 POST 表单直传 | 操作不支持 | 改成 PUT presigned URL,或换 server-side upload |
我会把上传 URL 的有效期先设成 10 分钟。头像、截图这类小文件足够用;CSV 或视频导入如果用户会等很久才点上传,就在真正开始上传前再请求一次 URL,不要把 7 天有效期当默认值。
团队排障时要留下哪些证据?
把下面几项放进同一条 issue,比反复问「你那边能不能复现」更快:
| 证据 | 示例 |
|---|---|
| 浏览器 origin | https://app.example.com 或 http://localhost:3000 |
| Network 里的预检方法 | OPTIONS -> Access-Control-Request-Method: PUT |
| 预检请求头 | Access-Control-Request-Headers: content-type,x-amz-meta-upload-id |
| 实际 PUT 头 | Content-Type: image/png、x-amz-meta-upload-id: ... |
| R2 CORS policy | 当前 bucket 的 JSON |
| URL 生成参数 | Bucket、Key 前缀、operation、expiresIn,不贴完整签名 URL |
| 错误响应 | 403 XML、console message、Cloudflare dashboard 时间点 |
如果问题只在生产出现,本地不出现,先比对 AllowedOrigins。如果只在某一种文件类型出现,先比对 Content-Type。如果换文件名后才失败,先看 object key 是否被前端或后端重复 URL encode。
这类排查有哪些不能省的限制?
这篇只处理浏览器用 R2 presigned URL 直接上传对象的路径,不处理 Workers binding 代理上传、multipart 分片上传、TUS resumable upload、R2 event notifications 和对象生命周期规则。
也不要把 CORS 当安全策略本身。CORS 只是浏览器访问控制;presigned URL 泄露后,在过期前仍然能被拿来执行对应操作。真正的限制要放在后端签名服务里:用户权限、对象 key 前缀、文件大小、MIME 白名单、短有效期、上传后校验和清理未确认对象。