Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

GCSのallUsers+objectViewerは、get専用のつもりがlistも渡っていた

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

roles/storage.objectViewerをallUsersに与えると、単体オブジェクトのgetだけでなくstorage.objects.listまで許可され、バケット内の全オブジェクト名を認証なしで列挙できる。

結論

roles/storage.objectViewerをallUsersに与えると、単体オブジェクトのgetだけでなくstorage.objects.listまで許可され、バケット内の全オブジェクト名を認証なしで列挙できる。 キミテラスv2の広告クリエイティブ配信バケット(ad_media)は、サイネージ端末が<img>タグで直接GETできるようallUsersに公開readを与えていたが、付与していたロールはroles/storage.objectViewerだった。欲しかったのは「キーを知っている1件を取得できること」だけだったが、このロールはstorage.objects.listも含むため、キーを知らない誰でもバケット内の全オブジェクト名を列挙できる状態になっていた。

症状

サイネージ端末・ブラウザが認証なしで広告画像を取得できるよう、ad_mediaモジュールは公開バケットにallUsersへの読み取り権限を付けていた。

# infrastructure/terraform/modules/ad_media/main.tf(修正前)
# 公開 read(オブジェクト閲覧のみ)。サイネージ端末・ブラウザが認証なしで画像を取得できる。
resource "google_storage_bucket_iam_member" "public_read" {
  count = var.enabled ? 1 : 0

  bucket = google_storage_bucket.ad_media[0].name
  role   = "roles/storage.objectViewer"
  member = "allUsers"
}

コメントは「オブジェクト閲覧のみ」と書いているが、roles/storage.objectViewerが実際に含む権限はstorage.objects.getだけではない。storage.objects.listも含まれているため、allUsersにこのロールを付けると、https://storage.googleapis.com/storage/v1/b/<bucket>/oというJSON APIのlistエンドポイントを誰でも認証なしで叩け、バケット内の全オブジェクト名を列挙できる。広告クリエイティブのファイル名には学校IDを含むキーが使われており、1件のURLを知っているだけでは見えない他校のキーまで、listを使えば全件見えてしまう状態だった。

原因

設計意図は「書込みはデプロイ/seedの権限主体に限り、allUsersにはreadのみ、しかもキーを知っている1件のGETだけを許す」というものだった。ところが実装で使ったroles/storage.objectViewerは、Google側の定義としてstorage.objects.getとstorage.objects.listの両方を含む事前定義ロールで、「オブジェクトを個別に取得できる」権限と「バケットの中身を一覧できる」権限を1つにまとめている。GCSの事前定義ロールの粒度が、このバケットで本当に許したかった権限の粒度より粗く、「公開readを与える」つもりの1行が「公開listを与える」ことも同時に意味してしまっていた。

直し方

allUsersへの付与を、storage.objects.getだけを含むproject custom roleに置き換えた。

# infrastructure/terraform/modules/ad_media/main.tf(修正後)
resource "google_project_iam_custom_role" "public_object_get" {
  count = var.enabled ? 1 : 0

  project     = var.project_id
  role_id     = var.public_get_role_id
  title       = "Ad media public object get"
  description = "storage.objects.get only (no list). Bound to allUsers on the public ad-media bucket."
  permissions = ["storage.objects.get"]
  stage       = "GA"
}

resource "google_storage_bucket_iam_member" "public_get" {
  count = var.enabled ? 1 : 0

  bucket = google_storage_bucket.ad_media[0].name
  role   = google_project_iam_custom_role.public_object_get[0].name
  member = "allUsers"
}

google_project_iam_custom_roleでpermissions = ["storage.objects.get"]だけを持つロールを作り、allUsersにはこのカスタムロールだけを付ける。配信に必要なのは「キーを知っているオブジェクトを1件GETできること」だけなので、既存のGCS直URL(広告のmedia_url)はこの変更後もそのまま取得できる。一方で.../oへのlistアクセスは、allUsersがget専用ロールしか持たないため拒否されるようになった。

project custom roleには固有の制約もある。

# infrastructure/terraform/modules/ad_media/variables.tf
variable "public_get_role_id" {
  type        = string
  default     = "adMediaPublicObjectGet"
  description = "allUsers に付与する get 専用 custom role の role_id(project 内で一意。削除後 37 日は再利用不可)。"
}

role_idはproject内で一意かつ、削除してから37日は同じidを再利用できない。既定値を変数で持たせておくことで、ロールを作り直す必要が出たときに名前を変えて再作成できるようにしている。

再発防止

この修正と同じコミットで、GitHub ActionsのWorkload Identity Federationにも同種の「事前定義の粒度が広すぎる」問題が見つかり、合わせて直されている。

# infrastructure/terraform/modules/workload_identity_federation/main.tf(修正前)
resource "google_service_account_iam_binding" "deploy_wif" {
  service_account_id = google_service_account.deploy.name
  role               = "roles/iam.workloadIdentityUser"
  members            = [local.repo_principal_set]
}

resource "google_service_account_iam_binding" "plan_wif" {
  service_account_id = google_service_account.plan.name
  role               = "roles/iam.workloadIdentityUser"
  members            = [local.repo_principal_set]
}

deploy用サービスアカウントへのworkloadIdentityUserバインディングが、リポジトリ全体を指すlocal.repo_principal_setに対して開かれていたが、実際にそのSAを使うワークフローは1つも無かった(デプロイはscripts/deploy/*からローカル実行)。使っていない経路を開けたままにしておくと、書込み権限を持つ協力者が任意のbranchのworkflowからSAになりすませる状態が残る。該当バインディングは削除し、read-only用のplan SAだけがlocal.repo_principal_setから引き続きimpersonateできる形にした。

# infrastructure/terraform/modules/workload_identity_federation/main.tf(修正後)
resource "google_service_account_iam_binding" "plan_wif" {
  service_account_id = google_service_account.plan.name
  role               = "roles/iam.workloadIdentityUser"
  members            = [local.repo_principal_set]
}

roles/storage.objectViewerもprincipalSetでの広いバインディングも、どちらも「公式の事前定義された単位」をそのまま使った結果、意図したより広い権限が一緒に付いてくるという同じ形の事故だった。GCPの事前定義ロール・IAMバインディングをallUsersやprincipalSetのような広い対象に付けるときは、そのロール・バインディングが実際に含む権限の一覧を確認し、必要な権限だけを含むカスタムロールやスコープへ絞り込む判断を、設計時に一度は通すようにしている。

よくある質問

Q1なぜroles/storage.objectViewerだとlistまで渡ってしまうのですか?

objectViewerはstorage.objects.getとstorage.objects.listの両方を含むロールだから。allUsersに付けると、個々のファイルを知らない人でも/storage/v1/b/<bucket>/oを叩くだけでバケット内の全オブジェクト名を列挙できてしまう。

Q2オブジェクト名には何が含まれていたのですか?

広告クリエイティブのファイル名には学校IDを含むキーが使われていた。GETだけなら知っているURLしか取れないが、listが使えると他校のキーまで含めて全件が見える状態だった。

Q3直し方はどう変えましたか?

allUsersへの付与をroles/storage.objectViewerから、storage.objects.getのみを含むproject custom role(google_project_iam_custom_role、permissions=["storage.objects.get"])に置き換えた。配信に必要なのはキー既知の単体GETだけなので、listを外しても既存のURLはそのまま使える。

Q4project custom roleを使うときの注意点はありますか?

role_idはproject内で一意で、削除してから37日は同じidを再利用できない。既定値を変数化しておくと、作り直しが必要になったときに名前をずらせる。

確認した環境

  • Terraform >= 1.9.0, < 2.0.0 / google provider ~> 6.0(キミテラス-v2 infrastructure/terraform/envs/prod)
  • 2026-10-01 の修正コミットで発覚・同日解消

この記事の根拠

  • Terraformファイル 1〜64行目コミット 9761682
  • Terraformファイル 1〜82行目コミット f4c36b7
  • Terraformファイル 41〜45行目コミット f4c36b7
  • Terraformファイル 1〜15行目コミット 9761682
  • Terraformファイル 120〜138行目コミット 9761682
  • Terraformファイル 1〜23行目コミット f4c36b7
  • Terraformファイル 128〜144行目コミット f4c36b7

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。