用户上传头像、合同 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,找到失败的 OPTIONSPUT,把 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 请求你要看什么读法
OPTIONSStatus、Origin、Access-Control-Request-Method、Access-Control-Request-Headers预检没过,后面的 PUT 通常不会真正发出
PUTStatus、Request Method、Content-Type、响应里的 XML 错误PUT 发出后 403,更多是签名、header 或过期时间问题
Console errorblocked 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/pngAllowedHeaders漏掉 Content-TypeContent-Type
SDK 或前端发送校验头AllowedHeaders漏掉 x-amz-checksum-* 或自定义 metadata把 Network 里出现的 header 加进去
前端要读取对象 hashExposeHeaders上传成功但 JS 读不到 ETagETag
频繁上传小文件MaxAgeSeconds每次都重新预检可设 3600,减少重复预检

Cloudflare 还提醒,只有带合法 Origin 的跨源请求才会返回 CORS 响应头。你用 curl 测时如果没加 Origin,看不到 Access-Control-* 头是正常的。

一个可直接改的 R2 CORS JSON 怎么写?

下面这份配置适合最常见的 SaaS 上传:生产前端、localhost、本地 PUT 上传、前端固定发送 Content-Type,上传后读取 ETag。如果你的前端还发送 Cache-ControlContent-MD5x-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 天,支持 GETHEADPUTDELETE,不支持 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 },
  );
}

这里的 ContentTypeCacheControlMetadata 会影响前端要发什么 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,比反复问「你那边能不能复现」更快:

证据示例
浏览器 originhttps://app.example.comhttp://localhost:3000
Network 里的预检方法OPTIONS -> Access-Control-Request-Method: PUT
预检请求头Access-Control-Request-Headers: content-type,x-amz-meta-upload-id
实际 PUT 头Content-Type: image/pngx-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 白名单、短有效期、上传后校验和清理未确认对象。

相关阅读