浏览器直传 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 地址
安装两个依赖:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner下面是可放进服务端项目的 presign.mjs。它只负责签名,不是一个已经完成登录鉴权的 HTTP 接口。
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,并且这个接口与网页同源。
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 示例:
[
{
"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 演示,因此生成签名时也必须选择这个类型。
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。需要让编程助手协助实现时,把签名输入、允许来源、上传方法和验收条件一起提供给它,问题会比一句“帮我解决跨域”清楚得多。