介绍
即使预签名 URL 可以在 curl 中正常工作,浏览器上传页面仍可能失败。浏览器还会检查存储服务是否允许该页面的源。你将为指定的模拟上传页面配置独立的 CORS 边界,同时保持存储桶为私有状态。
请先完成「授权临时文件访问」实验。本次实验提供一台全新的 VM,其中预装并固定使用 Node.js 22.22.0、Wrangler 4.131.1 和 AWS SDK 3.888.0,还提供了一个完整但与本实验无关的上传页面外壳。你将创建一个新的私有存储桶、一个短期对象凭据和一个临时 Worker 页面。R2 必须已经启用;请查看 R2 定价 和 CORS 行为。不需要购买域名。只能使用实验提供的模拟文本;完成后清理所有资源并撤销实验令牌。
连接应用存储桶
在本步骤中,为这台 VM 授权,并为应用创建一个独立的私有存储桶。设备授权用于确认你的学习账号。R2 存储桶管理使用另一个仅限该账号的 API 令牌。
启动 Bash,以便使用下面的命令语法。然后进入准备好的项目目录并检查工具版本。保持当前终端打开,这样资源名称变量会一直可用:
bash
cd /home/labex/project/r2-lab
export PATH="$PWD/.tools/node-v22.22.0-linux-x64/bin:$PATH"
node --version
npx wrangler --version
在你自己的浏览器中授权显示的设备代码。授予同意前,确认学习账号,以及请求的账号和用户读取权限范围:
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write
npx wrangler whoami --json
确认输出中的 loggedIn: true。即使列表中只有一个账号,也要读取账号名称。将下面的 YOUR_ACCOUNT_ID 替换为该账号实际的 32 位 ID。openssl rand -hex 6 会生成 12 个随机十六进制字符,避免本次实验与之前的运行冲突。此处的 Here Document 会写入标准配置文件;Shell 会将变量替换到文件中。
ACCOUNT_ID=YOUR_ACCOUNT_ID
RUN_ID=$(openssl rand -hex 6)
NAME="labex-c05-r05-$RUN_ID"
BUCKET="$NAME-docs"
cat > wrangler.jsonc <<JSON
{"name":"$NAME","account_id":"$ACCOUNT_ID","main":"src/index.js","workers_dev":true,"compatibility_date":"2026-07-30","r2_buckets":[{"binding":"DOCUMENTS","bucket_name":"$BUCKET"}]}
JSON
要管理存储桶,请打开 Cloudflare 个人资料中的 API Tokens 页面,并创建一个以本实验命名的自定义令牌。授予 Account → Workers R2 Storage → Edit 权限,并将 Account Resources 限制为你刚才保存 ID 的学习账号。设置较短的过期时间。不要包含其他账号或无关权限。此账号级权限可以创建和删除存储桶;下一步骤中的仅限对象操作令牌无法执行这些操作。
将令牌复制一次到这个隐藏的 VM 提示符中。umask 077 会将文件权限限制为仅你的用户可读;read -s 会隐藏输入内容。该文件使用 Wrangler 的标准令牌变量,并且会被 Git 排除:
umask 077
read -r -s -p 'R2 management API token: ' R2_MANAGEMENT_TOKEN; printf '\n'
printf 'CLOUDFLARE_API_TOKEN=%s\n' "$R2_MANAGEMENT_TOKEN" > .env.management
unset R2_MANAGEMENT_TOKEN
仅对 R2 管理命令使用 --env-file=.env.management;普通的 whoami 仍会检查 VM 的设备授权。
将 --env-file 放在每条 Wrangler 命令的末尾,避免它的文件参数列表把命令名称也当作参数。创建每个存储桶后,如果 Wrangler 询问是否向配置添加绑定,请输入 n 并按 Enter。配置中已经包含所需的绑定。
npx wrangler r2 bucket create "$BUCKET" --env-file=.env.management
列出你的存储桶,并找到刚生成的确切名称。其他存储桶属于其他任务,请不要修改:
npx wrangler r2 bucket list --env-file=.env.management
在 Dashboard 中打开 Storage & databases → R2 → Overview,选择这个确切的存储桶,并检查其空对象列表。在存储桶设置中,保持公共开发 URL 和自定义域名处于禁用状态。Dashboard 中显示的存储桶名称可以确认对象身份;稍后的下载检查会验证实际存储的字节。
Worker 脚本权限用于支持部署。KV 权限用于支持 Wrangler 的删除记录;本实验不会创建 KV 命名空间。R2 管理令牌仍然是一个独立的账号级凭据。
准备上传页面并配置签名访问
在本步骤中,部署实验提供的页面,并为终端配置一个仅限此存储桶的凭据。页面只包含固定的模拟负载和一个空的 URL 输入框;它永远不会接收长期签名密钥。
兼容 S3 的 API 可以让标准存储 SDK 访问 R2。它使用独立的访问密钥对,而不是 Wrangler 的设备令牌。在 R2 Overview 中,使用 Account Details → API Tokens → Manage,然后创建一个以本实验生成的资源名称命名的 User API token。选择 Object Read & Write,将权限限制为这个新建的存储桶;如果表单提供短期过期选项,请选择该选项。不要选择所有存储桶,也不要选择 Admin 权限。在保存这次性密钥之前,保留令牌页面以便查看。
在 VM 中使用以下 Bash 提示符。read -s 会隐藏输入内容;umask 077 会使凭据文件只能由你的用户读取。这些名称是 AWS SDK 使用的标准环境变量。将 Access Key ID 和 Secret Access Key 分别粘贴到对应提示符中,然后按 Enter。不要粘贴通用 API 令牌值。
umask 077
read -r -s -p 'Access Key ID: ' AWS_ACCESS_KEY_ID; printf '\n'
read -r -s -p 'Secret Access Key: ' AWS_SECRET_ACCESS_KEY; printf '\n'
printf 'AWS_ACCESS_KEY_ID=%s\nAWS_SECRET_ACCESS_KEY=%s\n' "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" > .env.s3
unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
写入一个可重复使用的标准 SDK 客户端。SDK 要求提供区域字符串;R2 使用 auto。读取现有配置,可以确保 CLI 和 SDK 操作针对同一个账号和存储桶。
cat > storage.mjs <<'JS'
import { S3Client } from "@aws-sdk/client-s3";
import { readFileSync } from "node:fs";
const config = JSON.parse(readFileSync("wrangler.jsonc", "utf8"));
export const Bucket = config.r2_buckets[0].bucket_name;
export const s3 = new S3Client({
region: "auto",
requestChecksumCalculation: "WHEN_REQUIRED",
endpoint: `https://${config.account_id}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
JS
requestChecksumCalculation: "WHEN_REQUIRED" 避免在浏览器提供上传内容之前,将空内容的校验和写入签名。URL 仍会签署对象和操作;下方签名调用会明确包含 Content-Type。最终检查会比对实际存储的字节。
发布实验提供的页面:
npx wrangler deploy
将 HTTPS 部署 URL 写入 ORIGIN,不要包含路径或末尾斜杠。一个 origin 由协议、主机名和端口组成;https://example.com 与 http://example.com 不同。保存该值,稍后进行独立检查:
ORIGIN=https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev
printf "%s\n" "$ORIGIN" > origin.txt
在浏览器中打开该 URL。确认页面显示 Synthetic export upload,并且临时 URL 输入框为空。暂时不要上传;此时存储桶还不允许这个浏览器源。
允许一个浏览器源
在本步骤中,配置 跨源资源共享(Cross-Origin Resource Sharing,CORS)。浏览器发送跨源 PUT 请求前,会先询问存储桶是否允许该页面的源、请求方法和请求标头。这个 OPTIONS 请求称为 预检(preflight)。CORS 控制浏览器访问权限,但不会替代已签名操作所需的存储授权。
使用页面的实际源写入策略。终端会将 $ORIGIN 替换到 JSON 中。Wrangler 使用 Cloudflare 的小写 rules/allowed 格式,而不是 AWS 的 CORSRules 格式:
cat > cors.json <<JSON
{"rules":[{"allowed":{"origins":["$ORIGIN"],"methods":["PUT"],"headers":["content-type"]},"exposeHeaders":["ETag"],"maxAgeSeconds":60}]}
JSON
这个页面只需要 PUT 和 Content-Type。exposeHeaders 允许 JavaScript 读取返回的 ETag;较短的 maxAgeSeconds 可以减少测试期间的预检缓存。
npx wrangler r2 bucket cors set "$BUCKET" --file cors.json --env-file=.env.management
npx wrangler r2 bucket cors list "$BUCKET" --env-file=.env.management
确认只为这个新建的存储桶覆盖 CORS 策略。结果列表必须显示你的确切源。在存储桶的 Dashboard 设置中,以只读方式检查 CORS 策略,并保持公共访问处于禁用状态。

示例:策略允许此页面来源、PUT 和 content-type。你生成的主机名会不同。CLI 输出还用于检查公开的 ETag 和缓存时长。
通过浏览器上传
在本步骤中,将已签名的 PUT 权限与浏览器源策略结合起来。前面学习过的签名脚本会为 uploads/browser.txt 精确生成 URL;页面发送的 Content-Type 必须与签名时使用的值相同。
cat > sign-upload.mjs <<'JS'
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { writeFileSync } from "node:fs";
import { s3, Bucket } from "./storage.mjs";
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket, Key: "uploads/browser.txt", ContentType: "text/plain"
}), { expiresIn: 300, signableHeaders: new Set(["content-type"]) });
writeFileSync("put-url.txt", url, { mode: 0o600 });
console.log("Prepared a five-minute PUT URL");
JS
node --env-file=.env.s3 sign-upload.mjs
只在复制临时链接到实验提供的页面期间显示它。不要分享该链接、将其放入截图,也不要粘贴 S3 密钥:
cat put-url.txt
将链接粘贴到 Temporary PUT URL 中,然后在五分钟内选择 Upload synthetic text。确认请求返回成功的 2xx HTTP 状态,并且页面显示 ETag。上传后清空 URL 输入框。过期 URL 可能表现为普通的浏览器 CORS 错误,因为错误响应可能不包含 CORS 标头;如有需要,请生成新的链接。
通过 Wrangler 下载实际对象,并与固定负载进行比较:
npx wrangler r2 object get "$BUCKET/uploads/browser.txt" --remote --file browser-download.txt --env-file=.env.management
printf "Synthetic browser export.\n" > expected-browser.txt
cmp expected-browser.txt browser-download.txt
平台检查会独立读取字节和内容类型。浏览器中的实际交互仍然是本实验所需的证据;单独使用 curl 不会执行浏览器 CORS 检查。

真实浏览器返回 HTTP 200 和可读取的 ETag;临时 URL 输入框已清空。页面显示字面量 \n,但上传的是实际换行符。独立下载检查确认载荷恰好为 26 字节。
区分源策略与签名权限
在本步骤中,检查允许和不允许的预检请求,但不创建另一个对象。这些 curl 请求用于询问浏览器被允许执行哪些操作;curl 本身不会强制执行返回的权限结果。
ACCOUNT_ID=$(node -p "JSON.parse(require('fs').readFileSync('wrangler.jsonc')).account_id")
OBJECT_URL="https://$ACCOUNT_ID.r2.cloudflarestorage.com/$BUCKET/uploads/browser.txt"
curl -i -X OPTIONS -H "Origin: $ORIGIN" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type" "$OBJECT_URL"
确认返回的 Access-Control-Allow-Origin 与页面的确切源一致。现在从无关源发起请求:
curl -i -X OPTIONS -H "Origin: https://outside.example" -H "Access-Control-Request-Method: PUT" -H "Access-Control-Request-Headers: content-type" "$OBJECT_URL"
响应不得授权该源,也不得返回 *。拒绝响应的具体状态码可能不同;真正阻止浏览器访问的是响应中没有授权信息。最后,尝试执行未签名读取:
curl -sS -o unsigned.xml -w "%{http_code}\n" "$OBJECT_URL"
此 R2 S3 端点的完全未签名请求应返回 400,XML Code 为 InvalidArgument,Message 为 Authorization。检查下方两个字段;任意错误或网络故障都不能证明签名被拒绝。
python3 - <<'PYXML'
from xml.etree import ElementTree
root = ElementTree.parse("unsigned.xml").getroot()
print("Code:", root.findtext("Code"))
print("Message:", root.findtext("Message"))
PYXML
启用 CORS 并不会使存储桶公开,也不会移除签名检查。带有有效签名的非浏览器客户端仍可以使用已授予的操作,而不论是否包含 Origin 标头;源字符串不是身份凭据。
删除上传资源和凭据
在本步骤中,删除 UI Worker、指定对象和存储桶。撤销凭据前,先确认资源已经不存在。
npx wrangler delete
npx wrangler r2 object delete "$BUCKET/uploads/browser.txt" --remote --env-file=.env.management
npx wrangler r2 bucket delete "$BUCKET" --env-file=.env.management
npx wrangler r2 bucket list --env-file=.env.management
确认列表中只涉及本实验生成的名称。刷新 Dashboard 中的 Worker 列表和存储桶列表。在管理授权仍然有效时运行平台清理检查。存储桶删除后,其中的 CORS 配置也会一并删除。
撤销实验凭据并退出登录
在本步骤中,关闭本次练习遗留的访问权限。在 R2 API Tokens 页面中,仅撤销以本实验命名的对象令牌。在个人资料的 API Tokens 页面中,撤销为本实验创建的独立 R2 管理令牌。删除存储桶不会撤销令牌,Wrangler 退出登录也不会撤销 S3 凭据。
撤销令牌后,删除本地凭据文件,并让此 VM 退出登录:
rm .env.s3 .env.management put-url.txt
npx wrangler logout
检查结构化身份信息。退出登录时返回非零状态是预期行为:
npx wrangler whoami --json || true
确认 loggedIn: false;保留你在 Dashboard 中的普通登录状态。平台会检查本地凭据是否已删除以及 Wrangler 是否已退出登录。两个令牌的撤销都需要在 Dashboard 中手动完成;本实验不会根据文件删除情况推断令牌是否已撤销。
总结
配置精确源 CORS,使用预签名 URL 上传模拟字节,区分浏览器权限与授权,并完成清理。



