TL;DR
11通りのFirestoreルールセットに、ゲスト、匿名ユーザー、サインイン済みの3人のユーザーとして同じリクエストを送りました。request.auth != nullでは、見知らぬ人が全員の注文を読み、変更し、削除できました。resourceだけでオーナーを確認するルールでは、ユーザーが注文を他人に渡せました。広い{document=**}のmatchが1つあるだけで、正しいオーナールールがあるコレクションが開いてしまいました。そして、ユーザーが書き込めるドキュメントにロールを保存していると、1回のリクエストで管理者になれました。それぞれの修正を下に載せてあり、修正後のルールは同じリクエストに耐えました。
Firebaseアプリには、ブラウザとデータベースの間に自分のサーバーがありません。プロジェクトの設定はページに含まれて配信され、それを持つ誰かとあなたのデータの間にあるのはセキュリティルールだけです。そこで、正しく見えるルールの一覧ではなく、攻撃を並べたルールの例が欲しくなりました。
テスト方法。 Firebase CLI 15.34.2のFirebase Local Emulator Suiteを、2026年11月3日に使いました。各ルールセットを読み込み、データをリセットし、Authエミュレーターの本物のトークンでFirestore REST APIを呼びました。トークンなし、匿名ユーザー、ユーザーAとB、そしてサインアップしただけの見知らぬ人です。データは架空で、注文a1とa2はAのもの、b1はBのものです。
どのFirebaseルールが漏らす?
| ルール | リクエスト | 結果 |
|---|---|---|
if true(テストモード) |
ゲストが注文を一覧する | 200、3件すべて |
if true(テストモード) |
ゲストがBの注文を削除する | 200 |
request.auth != null |
匿名ユーザーが注文を一覧する | 200、3件すべて |
request.auth != null |
見知らぬ人がAの注文を削除する | 200 |
resourceだけのオーナー確認 |
AがBの所有する注文を作成する | 200 |
resourceだけのオーナー確認 |
Aが自分の注文のownerIdを変更する |
200 |
オーナールールと{document=**}のread |
AがBの注文を読む | 200 |
ユーザーが自分のusersドキュメントに書ける |
Aがrole: adminを設定し、Bの注文を読む |
200 |
テストモードは実際に何を開く?
match /{document=**} {
allow read, write: if true;
}
すべてを、全員に開きます。トークンのないゲストが3件の注文をすべて一覧し、1件を削除し、新しい1件を作成しました。アカウントも漏えいしたシークレットも要りません。必要なのはプロジェクトIDだけです。
テストモードがよく使う期限付きの版は、別の壊れ方をします。if request.time < timestamp.date(2026, 1, 1)でその日付が過去なら、ゲストは403になり、ユーザーAが自分の注文を読むときも403でした。漏えいは終わりますが、アプリも終わります。
request.auth != nullで十分?
いいえ。リクエストの背後に何かしらのアカウントがある、というだけです。
match /orders/{id} {
allow read, write: if request.auth != null;
}
ゲストは403でした。だから、このルールは簡単なテストでは安全に見えます。ところが、サインアップしただけの見知らぬ人が、3件の注文をすべて一覧し、Bの注文の合計を変更し、Aの注文を削除し、どれも200でした。匿名ユーザーも3件すべてを一覧できました。サインアップが開放されているなら、このルールは「登録する手間をかけた人なら誰でも」という意味になります。Firebaseのドキュメントも、これをよくある危険なパターンとして挙げています。
オーナー確認にresourceとrequest.resourceの両方が要るのはなぜ?
resourceは保存されている状態のドキュメントです。request.resourceは書き込み後になるドキュメントです。前者だけを見るルールは、今その行が誰のものかを確認するだけで、それを何に変えるかについては何も言いません。
match /orders/{id} {
allow read, update, delete: if resource.data.ownerId == request.auth.uid;
allow create: if request.auth != null;
}
読み取りは正しく見えました。Aは自分の注文で200、Bの注文で403でした。書き込みはそうではありませんでした。AはownerIdをBにした新しい注文を作成でき、200でした。さらに自分の注文のownerIdをBに変更でき、これも200で、その後はBがそれを読めました。注文が請求書や残高を意味するアプリでは、ユーザーが他人のアカウントに書き込んでいることになります。
次のバージョンは、これをすべて拒否しました。
match /orders/{id} {
allow read, delete: if request.auth != null
&& resource.data.ownerId == request.auth.uid;
allow create: if request.auth != null
&& request.resource.data.ownerId == request.auth.uid;
allow update: if request.auth != null
&& resource.data.ownerId == request.auth.uid
&& request.resource.data.ownerId == resource.data.ownerId;
}
| Aのリクエスト | 結果 |
|---|---|
| 自分の注文を読む | 200 |
| Bの注文を読む、更新する、削除する | 403 |
| Bの所有する注文を作成する | 403 |
ownerIdをBに変更する |
403 |
それでも、Aは自分の注文のtotalを変更でき、200でした。クライアントにそのフィールドを設定させたくないなら、変更してよいフィールドを指定します。
allow update: if request.auth != null
&& resource.data.ownerId == request.auth.uid
&& request.resource.data.diff(resource.data).affectedKeys().hasOnly(['note']);
これで、totalの変更は403、noteの変更は200になりました。
正しいルールなのにクエリが失敗したのはなぜ?
ルールはフィルターではないからです。上のオーナールールの下で、Aがコレクション全体を要求すると、3件のうち2件がAのものでも403でした。Firestoreは、クエリを実際にある内容ではなく、返しうる内容で判断します。
| Aのリクエスト | 結果 |
|---|---|
| 注文をすべて一覧する | 403 |
ownerId == Aで検索 |
200、2件 |
ownerId == Bで検索 |
403 |
ルールを厳しくした後に一覧が壊れたら、直すのはクエリです。一覧を動かすためにルールを緩めると、上で見た広すぎるルールができあがります。
広いmatchは正しいルールを打ち消せる?
はい。ルールファイルが長いプロジェクトなら、最初に探すのはこれです。Firestoreは、一致するルールのどれか1つが許せばリクエストを許可します。
match /orders/{id} {
allow read: if request.auth != null
&& resource.data.ownerId == request.auth.uid;
}
match /{document=**} {
allow read: if request.auth != null;
}
最初のブロックは良いオーナールールに見えます。2つ目があると、AはBの注文を200で読め、見知らぬ人は3件の注文をすべて一覧し、同じ人がusersにあるAのドキュメントも読めました。最初のブロックには何の警告もありません。広いmatchを削除してください。後のルールでアクセスを取り戻す方法はありません。
ユーザーはどうやって自分を管理者にする?
このパターンは、ロールをドキュメントに持たせると必ず出てきます。
function isAdmin() {
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin';
}
match /users/{uid} {
allow read, write: if request.auth.uid == uid;
}
match /orders/{id} {
allow read: if resource.data.ownerId == request.auth.uid || isAdmin();
}
「ユーザーは自分のプロフィールを編集できる」と「管理者はすべての注文を読める」は、それぞれ筋が通っています。合わせると権限昇格になります。
修正は、クライアントがそのフィールドを書けなくすることです。書き込みルールにrequest.resource.data.role == resource.data.roleを加えると、同じリクエストが403になり、AはBの注文に入れないままでした。サーバーから設定するカスタムクレームも、クライアントが変更できないので、もう1つの方法です。
問題なかった2つのこと
親のルールは、サブコレクションに漏れません。サインイン済みの全員に/orders/{id}のallow readを付けたとき、Aは注文を読めましたが、その下の/orders/b1/notes/n1は403でした。これはドキュメントどおりの動作で、ルールは一致したパスにだけ適用されます。
ゲストは、トークンがないことを使った比較をすり抜けません。resource.data.ownerId == request.auth.uidは、request.auth != nullを別に書かなくても、ゲストに403を返しました。それでも私は確認を書きます。そのほうがルールが意図を語るからです。
自分のルールをどう確認する?
エミュレーターで、ゲスト、見知らぬ人、2人目のユーザーとしてリクエストを実行してください。上のすべてのケースを見つけたのはこれで、ルールを読むだけでは見つかりませんでした。
最初のチェックには、無料のFirebase ルールチェックが使えます。貼り付けたルールを、プロジェクトに触れずに読みます。この記事のルールセットでは、テストモード、サインインのみのアクセス、広いmatch、ユーザーが自分で書けるロールを指摘しました。読むのはテキストだけなので、データがどんな状態か、ルールがデプロイされたかどうかは分かりません。
ページにあるapiKeyは、それ自体は問題ではありません。Firebaseのウェブ設定は設計上公開されており、無料のシークレットスキャナーもそのように表示します。背後のデータを守るのはルールだからです。ファイルには別のルールがあり、ルールでは止められない漏えいもあります。それはFirebase Storageのルールで試しました。
制限。 Firestoreのみ、ローカルのエミュレーター、REST経由、架空のデータです。Realtime Databaseは別のルール言語を使うため、テストしていません。どのアプリがバックエンドを呼べるかを制限するApp Checkは対象外で、ルールの代わりにはなりません。本番は、私が確認していない細部でエミュレーターと違うかもしれません。