跳到主要内容
AnyStorage
免费下载

Cloudflare R2

R2 CORS 错误:没有返回跨域允许头

浏览器访问 Cloudflare R2 报 CORS,是存储桶策略的问题,不是代码的问题。各字段分别管什么、三种下发方式,以及 r2.dev 的坑。

R2 为什么不返回 Access-Control-Allow-Origin,CORS 策略每个字段控制什么,以及用面板、Wrangler 或 S3 API 下发策略的做法。

r2 cors 错误,r2 cors 策略,Cloudflare R2 Access-Control-Allow-Origin

控制台里的原话,以及它不是什么

text

Access to fetch at 'https://<ACCOUNT_ID>.r2.cloudflarestorage.com/bucket/key'
from origin 'http://localhost:3000' has been blocked by CORS policy:
Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

你的 fetch 没写错,预签名 URL 大概也是有效的,同一个对象用 curl 能顺利下载。是浏览器不肯把响应交给你的 JavaScript,因为 R2 没说"可以交"。

Cloudflare 的 R2 CORS 文档里有两个事实框定了整个问题。第一,策略挂在存储桶上,不在代码里。第二,「Only a cross-origin request will include CORS response headers」——R2 靠 Origin 头判断是否跨域。所以把同一个 URL 粘到地址栏里能打开,而这件事说明不了任何问题。

失败的表现与对应字段
控制台症状原因要改的字段
完全没有 Access-Control-Allow-Origin存储桶上没有 CORS 策略AllowedOrigins
GET 正常,只有 PUT 失败方法未被允许AllowedMethods
"Request header field content-type is not allowed"头没被列出AllowedHeaders
上传成功但 response.headers.get('etag')null头没有暴露ExposeHeaders
https://example.com 可用,https://www.example.com 不行源必须完全一致AllowedOrigins
403 且完全没有 CORS 头预签名 URL 过期重新生成 URL

1. 把策略写明白

R2 接受 S3 风格的 CORS JSON,五个字段:AllowedOriginsAllowedMethodsAllowedHeadersExposeHeadersMaxAgeSeconds。Cloudflare 自己的浏览器上传示例就是值得照抄的形状:

json

[
  {
    "AllowedOrigins": ["https://example.com"],
    "AllowedMethods": ["PUT"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

注意这个示例没有做的事:它没有在 AllowedHeaders 里写 "*",而是写出客户端真正会发的那个头名。这个错误背后的几条 Cloudflare 社区帖子,内容几乎都是:以为 "*" 会像在 AWS 上一样生效,结果预检始终不过,换成字面头名后一行就修好了。写清楚的成本是一行,消掉的是一整类怀疑。

通配符的规则从浏览器侧也值得知道。MDN 整理的 CORS 规范说明,响应带凭据的请求时,服务器对 Access-Control-Allow-Origin「must not specify the * wildcard ... but must instead specify an explicit origin」,同样的限制也适用于 Access-Control-Allow-HeadersAccess-Control-Allow-MethodsAccess-Control-Expose-Headers

2. 下发策略:面板、Wrangler、S3 API

面板:R2 object storage → 目标存储桶 → Settings,在 CORS Policy 下选 Add CORS policy,粘贴 JSON。

能留在仓库里的是 Wrangler 版本:

sh

npx wrangler r2 bucket cors set my-bucket --file cors.json
npx wrangler r2 bucket cors list my-bucket

R2 也说 S3 兼容 API,所以走 CLI 同样可以。AWS CLI 的区域填 auto——Cloudflare 的 CLI 示例注明它只是 SDK 要求,R2 并不使用:

sh

aws s3api put-bucket-cors \
  --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com \
  --bucket my-bucket \
  --cors-configuration file://cors.json

aws s3api get-bucket-cors \
  --endpoint-url https://<ACCOUNT_ID>.r2.cloudflarestorage.com \
  --bucket my-bucket

重测之前等几秒。R2 文档提醒策略变更需要时间传播,而刚刚缓存了一次失败预检的浏览器,在 MaxAgeSeconds 到期前不会再问一次。Cloudflare 还说明浏览器可能把这个缓存时长压到两小时左右。

3. 浏览器实际在问什么

失败的往往不是你的 PUT,而是预检。MDN 的说法是:「for 'preflighted' requests the browser first sends an HTTP request using the OPTIONS method to the resource on the other origin, in order to determine if the actual request is safe to send.」这个 OPTIONS 携带 Access-Control-Request-MethodAccess-Control-Request-Headers,策略必须同时满足两者。

不用浏览器也能复现,猜测立刻变成两秒的确认:

sh

curl -i -X OPTIONS \
  -H "Origin: https://example.com" \
  -H "Access-Control-Request-Method: PUT" \
  -H "Access-Control-Request-Headers: content-type" \
  "https://<ACCOUNT_ID>.r2.cloudflarestorage.com/my-bucket/test.png"

策略正确时,响应会带上与请求对应的 Access-Control-Allow-Origin-Methods-Headers。除此之外的结果都是策略问题,不是框架问题。

简单请求根本不触发预检:MDN 列出的是 GETHEADPOST 加上安全列表头——AcceptAccept-LanguageContent-Language、限定三种 MIME 的 Content-Type,以及 Range。一旦加上 x-amz-* 或 JSON 的 content type,就又回到预检范围。

4. 源是字符串,不是模式

AllowedOrigins 的每一项必须和浏览器的 Origin 完全一致:协议、主机、端口都算,不能写路径。http://localhost:3000http://localhost:5173 是两项,https://example.com 不覆盖 https://www.example.com,末尾多一个斜杠就不匹配。

5. r2.dev 与自定义域

如果你在用公开开发 URL 测试,先看这段。Cloudflare 明确写道「Public access through r2.dev subdomains is rate-limited and should only be used for development purposes」,并且警告不要把 CNAME 指向 r2.dev 子域,因为那是「an unsupported access path」。

自定义域的行为更顺,而且是写进文档的:挂了 CORS 策略的存储桶在接上自定义域后,会对跨域请求自动返回 CORS 响应头。由此有两个后果。自定义域在 Cloudflare 缓存之后,所以改完策略可能需要清缓存,旧的预检响应才会停止被返回。另外预签名 URL 是对生成时的主机签名的,为账户端点生成的 URL 在自定义域上不会通。

6. 连 CORS 头都没有的上传失败

过期看起来像 CORS 错误,但不是。R2 文档指出过期的预签名 URL 会返回不带 CORS 头的 403 ExpiredRequest,于是浏览器只报"缺少头",把真正原因藏了起来。动策略之前先看 X-Amz-Expires 和时钟。如果上传成功却读不到 ETag,那是 ExposeHeaders,改一个数组就够了。

有时候根本不必经过浏览器

CORS 是保护浏览器的机制,所以只对浏览器生效。桌面客户端自己签名、不发 Origin 头,任何策略都拦不住它——这就是为什么一个在 Web 应用里不可用的存储桶,同一分钟在应用里能正常浏览。

AnyStorage(v0.2.24;macOS 11+、Windows 10+、Ubuntu 20.04+)把 R2 当作 S3 兼容端点:账户端点 URL、R2 的密钥对,path-style 寻址与自定义区域都是显式设置。它通过内置的本地 WebDAV 服务(http://127.0.0.1:3211)挂载为驱动器,不需要 FUSE、WinFsp 或 macFUSE,免费版可用两个连接。只要是搬文件,完全不涉及 CORS 策略;要做 Web 应用,上面那份策略还是得写。

能不能干脆全放开?

AllowedOrigins 设成 ["*"] 并列出用到的方法,对一个放公开图片的存储桶是说得过去的。但不要去碰 AllowedHeaders"*":Cloudflare 自己的示例写的是头名,社区反馈里上传卡住的也正是这里。

昨天还好用,今天为什么不行?

常见答案三个:源变了(预览部署会分到新主机名)、存储桶重建后策略没了、预检响应是改动之前缓存的。用上面的 curl -X OPTIONS 看一眼 R2 现在到底返回什么。

服务端上传需要配 CORS 吗?

不需要。Worker、Node 进程、CI 任务都不受 CORS 约束,受约束的只有浏览器 JavaScript 发出的请求。

策略会影响 Worker 里的 R2 绑定吗?

不会。存储桶绑定走的是 Cloudflare 内部 API,不是 S3 端点,CORS 不在链路上。策略只对浏览器直接访问存储桶的请求有意义。

下一步