浏览器直传 R2 时,控制台经常只留下一句 CORS error。但真正失败的环节可能是预检,也可能是签名过期、Content-Type 不一致,甚至上传地址用了错误的域名。

这篇把流程拆成三步:服务端生成短时上传地址,浏览器向该地址发送文件,最后确认对象与访问策略。示例使用 AWS SDK for JavaScript v3。

先把签名和 CORS 分开

预签名 URL 解决的是“这个请求是否获得临时授权”。CORS 解决的是“浏览器能否从这个网页来源发请求、读取响应”。前者正确,不代表后者已经配置;放开 CORS,也不能修复一个错误签名。

浏览器先请求你的业务接口,例如 POST /api/uploads/presign。后端验证用户与用途后返回上传 URL,再由浏览器把文件直接 PUT 到 R2。R2 Access Key 和 Secret Key 只放在服务端环境中。

按照 Cloudflare 预签名 URL 文档,这里使用 S3 API endpoint:

代码
https://<ACCOUNT_ID>.r2.cloudflarestorage.com

不要在生成签名后把域名替换成自定义域名或 r2.dev,请求主机也是签名条件的一部分。公开下载域名和本次私有桶上传的 S3 地址,是两个不同用途。

服务端生成一个短时 PUT 地址

安装两个依赖:

bash
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

下面是可放进服务端项目的 presign.mjs。它只负责签名,不是一个已经完成登录鉴权的 HTTP 接口。

javascript
import { randomUUID } from 'node:crypto';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const required = [
  'R2_ACCOUNT_ID', 'R2_ACCESS_KEY_ID',
  'R2_SECRET_ACCESS_KEY', 'R2_BUCKET',
];
for (const name of required) {
  if (!process.env[name]) throw new Error(`Missing ${name}`);
}

const client = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
  },
  requestChecksumCalculation: 'WHEN_REQUIRED',
});

const allowedTypes = new Set(['image/jpeg', 'image/png', 'text/plain']);

export async function createUpload({ userId, contentType, size }) {
  if (typeof userId !== 'string' || !/^[a-zA-Z0-9_-]{1,80}$/.test(userId)) {
    throw new Error('Invalid user ID');
  }
  if (!allowedTypes.has(contentType)) throw new Error('Unsupported type');
  if (!Number.isSafeInteger(size) || size < 1 || size > 10 * 1024 * 1024) {
    throw new Error('File must be between 1 byte and 10 MiB');
  }

  const key = `uploads/${userId}/${randomUUID()}`;
  const uploadUrl = await getSignedUrl(
    client,
    new PutObjectCommand({
      Bucket: process.env.R2_BUCKET,
      Key: key,
      ContentType: contentType,
    }),
    {
      expiresIn: 300,
      signableHeaders: new Set(['content-type']),
    },
  );
  return { key, uploadUrl, contentType, expiresIn: 300 };
}

userId 应来自后端已经验证的登录身份,不能直接相信浏览器提交的用户 ID。这里限制了演示 ID 格式,如果你的认证系统采用其他格式,应先映射为稳定、安全的对象前缀。

对象 key 在服务端生成,避免用户指定任意路径覆盖其他对象。R2 凭据只给需要的桶和操作授权;日志中也不要记录完整预签名 URL,因为拿到它的人在有效期内可以使用对应权限。

这里显式签入 Content-Type,浏览器必须使用返回的同一个值。requestChecksumCalculation: 'WHEN_REQUIRED' 则让这个最小例子不额外依赖 SDK 自动添加的可选校验和;如果你的业务要使用校验和,需要同时处理签名字段、浏览器请求和 CORS 允许头,不能只删掉报错字段。

这段 size 校验只检查客户端声明,不等于 R2 已强制限制真实上传大小。上传后仍需由服务端核对对象大小、实际文件内容与所属用户,再决定能否进入公开或业务可用状态。客户端的 MIME 类型同样不是文件内容的可信证明。SDK 接入方式可对照 Cloudflare 的 v3 示例。

本次使用两个 AWS SDK 包的 3.1141.0 版本,在虚拟凭据下验证了离线签名:有效期为 300 秒,签名头包含 content-type;host,五类非法输入会被拒绝。没有使用生产凭据执行真实 R2 上传,CORS 与对象落盘仍需按下文在实际桶中验收。

浏览器上传原始 File,不要包装成 FormData

你的 POST /api/uploads/presign 接口应完成登录校验和限流,读取客户端的 contentType、size,再调用上面的函数。下面假定它返回相同结构的 JSON,并且这个接口与网页同源。

javascript
export async function uploadFile(file) {
  const contentType = file.type || 'application/octet-stream';
  const signed = await fetch('/api/uploads/presign', {
    method: 'POST',
    credentials: 'same-origin',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ contentType, size: file.size }),
  });
  if (!signed.ok) throw new Error(`Signing failed: ${signed.status}`);
  const { uploadUrl, key, contentType: signedType } = await signed.json();

  const uploaded = await fetch(uploadUrl, {
    method: 'PUT',
    headers: { 'Content-Type': signedType },
    body: file,
  });
  if (!uploaded.ok) throw new Error(`Upload failed: ${uploaded.status}`);
  return { key, etag: uploaded.headers.get('ETag') };
}

前面的服务端允许列表没有接受 application/octet-stream,所以无法识别类型的文件会被拒绝签名。这是有意保守的行为;需要支持更多类型时,明确增加服务端规则,不要随意把未知文件声明成图片。

PUT 的 body 就是原始 File 或 Blob。如果套上 FormData,对象可能保存成带 multipart 边界的内容,Content-Type 也可能不再符合签名。这不是当前示例要使用的表单上传协议。相关数据类型可以参考 Node.js 中的 File、Blob、Buffer 与字符串。

预签名请求已经在 URL 中携带授权,不需要再额外加一份 Authorization 头,也不需要把网站 Cookie 发送给 R2。

在 R2 桶上配置 CORS

进入 Cloudflare 控制台对应桶的 CORS 设置,按网页的实际来源配置。下面是控制台使用的策略 JSON 示例:

json
[
  {
    "AllowedOrigins": [
      "http://localhost:5173",
      "https://app.example.com"
    ],
    "AllowedMethods": ["PUT", "GET", "HEAD"],
    "AllowedHeaders": ["Content-Type"],
    "ExposeHeaders": ["ETag"],
    "MaxAgeSeconds": 3600
  }
]

把示例域名换成真实来源。Origin 只包含协议、主机与端口,不包含页面路径;http://localhost:5173 和 http://127.0.0.1:5173 也是不同来源。

AllowedMethods 填实际请求方法。浏览器会发 OPTIONS 预检,但不要因此把它当作要加入策略的上传方法。这个例子只发送 Content-Type;若实际请求多了其他头,就要核对它们为什么出现,再有针对性地加入允许列表。

ExposeHeaders 控制前端能读取哪些响应头。没有暴露 ETag 时,上传可能已经成功,但 JavaScript 读取结果是 null。另外,不要把所有对象的 ETag 都当成文件 MD5,多段上传等情况可能有不同语义。完整策略说明见 R2 CORS 文档。

用 Network 面板定位失败步骤

先检查业务签名接口,再检查 R2 请求。如果签名接口本身返回 401,那是你的网站登录问题,还没有走到 R2 上传。

R2 请求可以分为下面几种情况:

左右滑动查看完整表格

观察结果

更可能的原因

下一步

OPTIONS 失败,没有 PUT

CORS 来源、方法或请求头不匹配

检查预检的 Origin 和 Access-Control-Request-Headers

OPTIONS 通过,PUT 返回 403

签名、过期时间、权限或请求字段

读取实际响应;检查 URL 是否被改写及 Content-Type 是否一致

PUT 2xx,前端拿不到 ETag

响应头没有暴露

核对 ExposeHeaders

上传 2xx,下载文件异常

body 或 Content-Type 错误

检查是否误用了 FormData,并比较原始文件

上传成功,公开域名无法读取

对象访问方式另有配置

区分私有桶、公开域名与签名下载,不要直接公开整个桶来排障

浏览器可能因为错误响应缺少 CORS 头,只展示跨域提示,把真正的签名错误遮住。这时要检查网络面板和服务响应,而不是不断扩大允许来源。

curl 可以帮助区分授权与浏览器限制

先取得一个新签名 URL,把它只保存在当前终端变量 UPLOAD_URL 中,不要粘到公共日志。下面用 text/plain 演示,因此生成签名时也必须选择这个类型。

bash
curl -i -X OPTIONS "$UPLOAD_URL" \
  -H 'Origin: http://localhost:5173' \
  -H 'Access-Control-Request-Method: PUT' \
  -H 'Access-Control-Request-Headers: content-type'

printf 'hello R2\n' > demo.txt
curl -i --upload-file demo.txt "$UPLOAD_URL" \
  -H 'Origin: http://localhost:5173' \
  -H 'Content-Type: text/plain'

OPTIONS 响应应允许你的来源、PUT 方法和 Content-Type。curl 上传成功只说明对应请求可以完成,curl 不会像浏览器一样执行 CORS 限制;最后仍要回到网页验证。浏览器限制的原理可参考 MDN CORS 指南。

过期时重新签名,不要修改旧 URL 的时间参数。重试时还要注意:同一个有效 PUT URL 可能允许重复覆盖同一个 key,所以“返回上传成功”之后的业务确认最好具有幂等性。

上传成功之后还差一步

让前端把 key 交回业务接口,服务端确认该 key 是为当前用户签发的、对象已经存在、大小和内容符合要求,再将它标记为可用。不要接受任意 key 并直接认领,也不要因为返回了 ETag 就跳过内容校验。

如果只是在排查已有错误,可以对照本站较早的英文记录 How to Fix CORS Error with R2 Presigned URLs。需要让编程助手协助实现时,把签名输入、允许来源、上传方法和验收条件一起提供给它,问题会比一句“帮我解决跨域”清楚得多。