意図しない公開ダウンロードを停止する

CloudflareBeginner
オンラインで実践に進む

はじめに

非公開バケットでも、公開 Worker がすべての呼び出し元にファイルを返すと、ファイルが漏えいする可能性があります。この実験では、2 つの合成エクスポートだけを使ってその欠陥を再現し、読み取りユーザー単位のアプリケーション認可を追加します。そのうえで、非公開ダウンロードが保護されたまま、公開ヘルスチェックには引き続きアクセスできることを確認します。

まず、Worker ドキュメント統合のレッスンと一時アクセスのレッスンを完了してください。この新しい VM には、意図的に安全でないハンドラー、合成ファイル、Node.js 22.22.0、Wrangler 4.131.1、Miniflare 4.20260730.0 が用意されています。新しい非公開バケットと使い捨ての Worker を作成します。アクティブな R2 と、対応する学習アカウントの権限が必要です。料金を確認してください。実際のファイル、実際の顧客データ、カスタムドメインは必要ありません。終了する前に、露出させたデモとその認証情報を必ず削除してください。

アプリケーション用バケットを接続する

このステップでは、この 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

表示されたデバイスコードを、自分のブラウザーで認証します。同意する前に、学習アカウントと、要求された account および user の読み取りスコープを確認してください。

npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write
npx wrangler whoami --json

loggedIn: true になっていることを確認します。一覧に 1 つしか表示されない場合でも、アカウント名を読み取ってください。以下の YOUR_ACCOUNT_ID を、そのアカウントの実際の 32 文字の ID に置き換えます。openssl rand -hex 6 は 12 個のランダムな 16 進数文字を生成するため、以前の実行と名前が衝突しません。ヒアドキュメントによって標準の設定ファイルが作成され、シェルが変数をその中に展開します。

ACCOUNT_ID=YOUR_ACCOUNT_ID
RUN_ID=$(openssl rand -hex 6)
NAME="labex-c05-r08-$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 を保存した学習アカウントだけに制限してください。有効期限は短く設定します。ほかのアカウントや関係のない権限は含めないでください。この管理トークンは、作成や削除などのバケット管理に使用します。この実験では、Worker は DOCUMENTS バインディングを通じて R2 オブジェクトにアクセスします。

トークンを一度だけコピーし、この非表示の 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 namespace を作成しません。R2 管理トークンは、引き続きアカウント単位の別の認証情報です。

合成された読み取りユーザー用に、新しいアプリケーション認証情報を 2 つ作成します。これらは Cloudflare API トークンではありません。所有者を表す 2 つのオブジェクトを登録し、意図的に露出させたハンドラーを公開します。

umask 077
printf "BLUE_TOKEN=%s\nGREEN_TOKEN=%s\n" "$(openssl rand -hex 24)" "$(openssl rand -hex 24)" > .dev.vars
npx wrangler r2 object put "$BUCKET/exports/blue/report.txt" --remote --file blue.txt --content-type text/plain --env-file=.env.management
npx wrangler r2 object put "$BUCKET/exports/green/report.txt" --remote --file green.txt --content-type text/plain --env-file=.env.management
npx wrangler deploy
npx wrangler secret bulk .dev.vars

この意図的に露出させたデプロイに含まれるのは、これら 2 つの合成ファイルだけです。実際のエクスポートを使用したり、実験後も実行したままにしたりしないでください。

意図しない公開ダウンロードを確認する

このステップでは、基盤のバケットが非公開のままでも、Worker 経由でファイルが露出することを再現します。binding によってサーバー側コードはバケットへアクセスできますが、R2 がそのコードを信頼すべき HTTP 呼び出し元を自動的に判断するわけではありません。

デプロイの正確な URL をコピーし、認証情報なしで blue のエクスポートを要求します。

BASE_URL=https://YOUR_WORKER.YOUR_SUBDOMAIN.workers.dev
curl -i "$BASE_URL/exports/blue/report.txt"

HTTP 200 と Synthetic blue export. が返ることを確認します。新しいデプロイがまだ反映中の場合は、最大 1 分間、読み取りを再試行してください。この匿名リクエストが成功することが、修正すべき欠陥です。

用意されたハンドラーを確認します。

cat src/index.js

このハンドラーは URL から R2 キーを直接読み取り、そのファイルを呼び出し元が所有しているかを判断せずに本文を返します。Dashboard でバケット設定を確認してください。公開開発 URL は無効で、カスタムドメインも設定されていない状態です。これらの設定では、Worker の別ルートは閉じられません。ハンドラーを置き換える前に、露出確認を実行します。

実際の設定ではカスタムドメインがなく、公開開発 URL も無効です。それでも R2 バインディングを持つ Worker は独自のルートからデータを公開できてしまいます。

プライベートバケットの設定

実際の VM ターミナルで、認証情報なしのリクエストが HTTP 200 と青チームの合成レポートを返しています。ユーザー名、リソース接頭辞、Worker URL は例です。自分のデプロイ URL を使ってください。

修復前の匿名 HTTP 200

キーを選択する前に読み取りユーザーを認証する

このステップでは、authentication(有効な認証情報を持つ読み取りユーザーかどうか)と authorization(そのユーザーがこのオブジェクトをダウンロードできるかどうか)を関連付けます。有効な blue トークンで green のエクスポートを取得できてはいけません。認証された識別情報からキーの所有者を決め、R2 に問い合わせる前に URL の所有者と一致することを確認します。

ハンドラーを置き換えます。ここで使用する 2 つの合成認証情報は、小規模な例における読み取りユーザーを表します。実際のアプリケーションでは、セッションまたは identity provider と、信頼できる権限レコードを使用します。呼び出し元が指定した名前を、識別情報の証明として受け入れてはいけません。

cat > src/index.js <<'JS'
async function matches(actual, secret) {
  if (!secret) return false;
  const expected = `Bearer ${secret}`;
  const a = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(actual));
  const b = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(expected));
  return crypto.subtle.timingSafeEqual(a, b);
}
export default {
  async fetch(request, env) {
    const path = new URL(request.url).pathname;
    if (path === "/health" && request.method === "GET") return new Response("ok");
    const actual = request.headers.get("Authorization") || "";
    const owner = await matches(actual, env.BLUE_TOKEN) ? "blue" : await matches(actual, env.GREEN_TOKEN) ? "green" : null;
    if (!owner) return new Response("Unauthorized", { status: 401 });
    if (request.method !== "GET") return new Response("Method not allowed", { status: 405 });
    const route = /^\/exports\/(blue|green)\/([a-z0-9-]+\.txt)$/.exec(path);
    if (!route) return new Response("Not found", { status: 404 });
    if (route[1] !== owner) return new Response("Forbidden", { status: 403 });
    // The authenticated owner and validated path determine the storage key.
    const key = `exports/${owner}/${route[2]}`;
    const object = await env.DOCUMENTS.get(key);
    if (!object) return new Response("Not found", { status: 404 });
    const headers = new Headers({ "Cache-Control": "private, no-store" });
    object.writeHttpMetadata(headers);
    headers.set("ETag", object.httpEtag);
    return new Response(object.body, { headers });
  }
};
JS

シークレットがない場合、呼び出し元と誤って一致することはありません。ダイジェストの比較には、ランタイムのタイミングセーフな等価性判定を使用します。health は保護されたルートの外側にあるため、引き続き利用できます。また、非公開レスポンスは共有キャッシュの対象になりません。クエリ文字列の key によって、認証済みのストレージパスを上書きすることもできません。

修正をデプロイします。

npx wrangler deploy

プラットフォームチェックでは、まずランダムな合成認証情報とオブジェクトのバイト列を使って、別のローカルランタイムを実行します。デプロイした修正を評価する前にローカルチェックを通過させると便利です。

読み取りユーザーの分離とヘルスチェックの維持を証明する

このステップでは、許可されるパスと拒否されるパスの両方を確認します。合成認証情報を表示せずに読み込み、それぞれの所有者のファイルをダウンロードします。

set -a
source .dev.vars
set +a
curl -fsS -H "Authorization: Bearer $BLUE_TOKEN" "$BASE_URL/exports/blue/report.txt" -o blue-download.txt
cmp blue.txt blue-download.txt
curl -fsS -H "Authorization: Bearer $GREEN_TOKEN" "$BASE_URL/exports/green/report.txt" -o green-download.txt
cmp green.txt green-download.txt

バイト列が完全に一致することを確認します。次に、匿名アクセス、別の所有者のパスへのアクセス、所有者自身の存在しないキーへのアクセス、公開ヘルスチェックをテストします。

curl -i "$BASE_URL/exports/blue/report.txt"
curl -i -H "Authorization: Bearer $BLUE_TOKEN" "$BASE_URL/exports/green/report.txt"
curl -i -H "Authorization: Bearer $BLUE_TOKEN" "$BASE_URL/exports/blue/missing.txt"
curl -i "$BASE_URL/health"

それぞれ 401 Unauthorized、403 Forbidden、404 Not found、200 ok が返ることを確認します。ステータスと本文が一致していなければなりません。プラットフォームチェックでは、リモート環境で読み取りユーザーの分離を再確認し、選択したアカウントが Worker とその非公開 R2 binding を所有していることを検証します。

同じ例の Worker URL が、VM ターミナルの匿名リクエストに HTTP 401 Unauthorized を返します。これはターミナルの HTTP 結果です。認証付きのバイト比較と独立したチェックで読者の分離を確認します。

修復後の匿名 HTTP 401

リモートのアプリケーションとバケットを削除する

このステップでは、認証済みの状態で、この実験で作成した Worker とオブジェクトだけを削除します。Worker を削除しても、非公開バケットは消えません。

npx wrangler delete

生成された Worker 名が正しいことを確認します。アップロードしたオブジェクトを明示的に 1 つずつ削除し、その後でバケットを削除します。

BUCKET=$(node -p "JSON.parse(require('fs').readFileSync('wrangler.jsonc')).r2_buckets[0].bucket_name")
npx wrangler r2 object delete "$BUCKET/exports/blue/report.txt" --remote --env-file=.env.management
npx wrangler r2 object delete "$BUCKET/exports/green/report.txt" --remote --env-file=.env.management
npx wrangler r2 bucket delete "$BUCKET" --env-file=.env.management

作成したのは blue と green のレポートキーだけです。正確なキーを削除し、アカウント内の関係のないリソースは残してください。

Dashboard で Worker とバケットの一覧を更新し、プラットフォームのクリーンアップチェックを実行します。認証またはネットワークの失敗は判定不能であり、削除成功を意味しません。

残った認証情報を無効化する

このステップでは、プロファイルの API Tokens ページでこの実験の管理トークンを失効させ、ローカルのアプリケーションシークレットを削除し、VM の認証を終了します。前のクリーンアップチェックが成功した後にのみ実行してください。

rm .env.management .dev.vars
unset BLUE_TOKEN GREEN_TOKEN
npx wrangler logout
npx wrangler whoami --json || true

loggedIn: false になっていることを確認します。管理トークンの失効は、Dashboard で別途手動確認する必要があります。ローカルファイルを削除しただけでは、トークンは失効しません。通常の Dashboard ログインや、ほかの実験で使用するトークンには触れないでください。

まとめ

合成 Worker ファイルの露出を停止し、読み取りユーザーの識別情報をオブジェクト所有者に関連付け、ヘルスチェックへのアクセスを維持し、非公開バケットのクリーンアップを確認しました。