コンソールに出る文と、その意味
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 なら同じオブジェクトが素直に落ちてきます。ブラウザが、R2 から「渡してよい」と言われなかったので JavaScript に結果を渡さなかった、それだけです。
Cloudflare の R2 CORS ドキュメントにある二つの事実が、この問題の全体像を決めます。一つ、ポリシーはコードではなくバケットに置く。二つ、「Only a cross-origin request will include CORS response headers」——R2 は Origin ヘッダーの有無でそれを判定します。だからアドレスバーに貼って開いても成功するし、その成功は何の情報にもなりません。
| コンソールの症状 | 原因 | 直すフィールド |
|---|---|---|
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 |
| CORS ヘッダーのない 403 | 署名付き URL の期限切れ | URL を作り直す |
1. ポリシーを明示的に書く
R2 は S3 形式の CORS JSON を受け取ります。フィールドは AllowedOrigins、AllowedMethods、AllowedHeaders、ExposeHeaders、MaxAgeSeconds の五つ。Cloudflare 自身のブラウザアップロード例が、そのまま写す価値のある形です。
json
[
{
"AllowedOrigins": ["https://example.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["Content-Type"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3600
}
]この例がしていないことに注目してください。AllowedHeaders に "*" を入れていません。クライアントが実際に送るヘッダー名を書いています。このエラーの背後にある Cloudflare Community のスレッド群は、AWS と同じように "*" が効くと思って書いたポリシーでプリフライトが通らず、リテラルなヘッダー名に置き換えた一行で直った、という話で埋まっています。明示するコストは一行、消える疑いは一クラスぶんです。
ワイルドカードの扱いはブラウザ側の規則としても知っておく価値があります。MDN が整理する CORS 仕様では、資格情報付きのリクエストに応答するとき、サーバーは Access-Control-Allow-Origin に「must not specify the * wildcard ... but must instead specify an explicit origin」とされ、同じ制限が Access-Control-Allow-Headers、Access-Control-Allow-Methods、Access-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-bucketR2 は S3 互換 API も話すので、そちら経由でも設定できます。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 はその上限がブラウザ側で 2 時間程度に丸められる場合があるとも書いています。
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-Method と Access-Control-Request-Headers を運び、ポリシーは両方を満たす必要があります。
ブラウザなしで再現できます。推測が 2 秒の確認に変わります。
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 は GET、HEAD、POST と、セーフリストされたヘッダー(Accept、Accept-Language、Content-Language、3 種の MIME に限った Content-Type、Range)だけの場合を挙げています。x-amz-* や JSON の content type を足した時点でプリフライト圏内です。
4. オリジンはパターンではなく文字列
AllowedOrigins の各要素はブラウザの Origin と完全一致でなければなりません。スキーム、ホスト、ポートまで含み、パスは書けません。http://localhost:3000 と http://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」と明記し、r2.dev のサブドメインへ CNAME を向けることは「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 のアクセスキー、パススタイル指定とカスタムリージョンを明示設定として持ち、内蔵のローカル WebDAV サーバー(http://127.0.0.1:3211)経由でドライブとしてマウントできます。FUSE も WinFsp も macFUSE も不要、無料プランの接続数は 2 つ。ファイルを移すだけなら CORS ポリシーは一切関係なく、Web アプリを作るなら上のポリシーは必要です。
全部許可してしまっては?
AllowedOrigins: ["*"] に使うメソッドだけ並べる構成は、公開画像のバケットなら十分に妥当です。ただし AllowedHeaders の "*" には手を出さないこと。Cloudflare の例はヘッダー名を書いていますし、コミュニティの報告でもアップロードが止まるのはそこです。
昨日は動いていたのに?
よくある答えは三つ。オリジンが変わった(プレビュー環境は新しいホスト名になる)、バケットを作り直してポリシーが消えた、変更前のプリフライトがキャッシュされている。上の curl -X OPTIONS で「今の R2 が何を返すか」を見てください。
サーバー側からのアップロードにも CORS 設定は必要?
不要です。Worker、Node のプロセス、CI ジョブは CORS の対象外です。対象はブラウザの JavaScript が出すリクエストだけです。
Worker の R2 バインディングには影響しますか?
しません。バケットバインディングは S3 エンドポイントではなく Cloudflare 内部の API を通るので、CORS は経路に登場しません。ポリシーが効くのはブラウザが直接バケットを叩く場合だけです。
次に読む
- バケットをドライブにする:Cloudflare R2 をローカルドライブとしてマウント
- 署名側の兄弟エラー:MinIO の SignatureDoesNotMatch
- 期限付きリンク:S3 署名付き URL ジェネレーター