先日、オンライン会議関連の機能を提供するサービスに対して、あるセキュリティ研究者が脆弱性を指摘するブログを公開した。
僕はWebシステムを開発することを生業にしているから、このような事例は、一歩間違えば明日は我が身とも言える。だからこそ、しっかり理解を深めておきたい。そこで今回は、このことについて掘り下げてみようと思う。
とあるセキュリティ研究者の脆弱性報告
冒頭で説明したように、とあるセキュリティ研究者が自身のブログで、オンライン会議関連の機能を提供するサービスに対する脆弱性を指摘した1。このサービスはオンライン会議の録画・文字起こし・要約の機能を提供している。
脆弱性報告の要点
要点を抜き出すと、以下のようになるだろう。
- 認証済みであれば、他者の会議情報(meetings)を横断してクエリできる状態になっていた。
- 開催中の会議中へのリアルタイムの侵入が可能だった。
- 影響範囲が大きい(84,312ユーザー、181,874件のメタデータ、政府・大学・企業にも影響)。
- 認証のないAPIから従業員の氏名・社用メールが露出していた。
- 報告から6ヶ月間、脆弱性が放置された。
この一覧は要点を一覧にしただけなので、粒度も性質もまちまちだ。詳細は、そのブログを読んで欲しい。
この中の1や2は、認証さえしてしまえば他人や他社の会議情報を取得したり、ミーティングに参加できたりしたようだ。よくよく考えると、これらはマルチテナントSaaSでは特に注意すべきことがらであり、同時に起こりそうなミスだとも言える。今回はここに注目して、何が起きて、どうすれば防げそうかを考える。
何が起きていたのか
今回の問題はどうやらFirestore Security Rulesのテナント分離漏れのようだ。報告の中では、次のように述べられている。
Fix the Firestore tenant isolation. Firestore security rules exist for this. You already do it correctly for every other collection (users, chats, transcripts, clips, recordings, videos, notes, teams, organizations all return 403). You just forgot meetings.
users、chats、transcripts などのコレクションは403が返るのに、meetingsだけは読むことができた、ということのようだ。ただし、実際にどのようになっていたかについては現在知りようもない。
想像で認可漏れを再現してみる
当然だが実際のルールのコードは公開されていないため、本当はどのようになっていたかは分からない。ここでは今回の事例からは離れ、典型的な認可漏れを実際に実装して、どのような状態かを確認する。これらは、あくまで「こう書くと同じような症状が起きる」という一例であり、先の事例とは基本的には無関係ということに注意して欲しい。
Firestoreはフロントエンドから直接クエリを投げられる
再現の前に、Firestoreの構造を整理しておく。Firestoreには、いわゆる自作のAPIサーバーが無い。フロントエンドがFirebase SDKを使ってFirestoreに直接クエリを投げる。
+--------------------+ +-----------------------------------+
| Frontend | | Firestore |
| | 任意の | |
| 如何様なクエリも | ---クエリ--> | クエリが返そうとする |
| 組み立てられる | | ドキュメント1件ごとに |
| | | Security Rules を評価 |
+--------------------+ +-----------------+-----------------+
|
+-----------------+-----------------+
| |
+-------v-------+ +-------v-------+
| users | | meetings |
| orgId の一致 | | 認証済みかだけ|
| まで確認cGRE | | 確認cPNK |
| -> 許可/拒否 | | -> 常に許可 |
+---------------+ +---------------+
つまり、フロントエンドが何を送ってこようと、実際にデータが返るかどうかはFirestore側、より正確には各コレクションに設定されたSecurity Rulesが、返そうとしているドキュメント1件ごとに判定する。システムがマルチテナントを想定している場合、このSecurity Rulesこそが、「テナントの異なるデータを返さない」ための唯一の砦になる。
認可のある例とない例を並べて問題を確認する
まず、認可のある例としてusersコレクションを考える。読み取り時に、自分が所属する組織(orgId)と、対象データの組織が一致するかを確認している。
match /users/{userId} {
allow read: if request.auth != null
&& resource.data.orgId == getUserOrgId(request.auth.uid);
}
function getUserOrgId(uid) {
return get(/databases/$(database)/documents/members/$(uid)).data.orgId;
}一方、meetingsコレクションを次のように書いたとしたらどうだろうか。ログインさえしていれば読めてしまい、組織(orgId)の突き合わせが抜けている。
match /meetings/{meetingId} {
allow read: if request.auth != null;
}この2つの違いは、usersは「ログインしているか(認証)」に加えて「自分の組織のデータか(認可)」まで確認しているのに対し、meetingsは「ログインしているか」しか確認していない、ということだ。
このように、コレクションごとに1回だけ書くはずの認可の確認を、1箇所だけ書き忘れると、そのコレクションへの、あらゆる経路からのアクセスが無防備になる。正しく実装されていれば、フロントエンドがどんなクエリを組み立てて送ってきてもSecurity Rulesがそれを止めてくれるはずだが、認可漏れの例のような実装ではmeetingsに関してだけは止まらないことになってしまう。
問題を修正する
それでは、さきほどの問題のあるmeetingsのルールを修正してみよう。
match /meetings/{meetingId} {
allow read: if request.auth != null
&& resource.data.orgId == getUserOrgId(request.auth.uid);
}当然このようになる。実際に修正するのはそれほど難しくない。ちゃんとorgIdを確認する条件を追加すればよいだけだ。
現実的な対策
問題のあるルールの修正は、たった1行の条件を足すだけだった。この問題はテストで防げたかもしれないし、コードレビューで防げたかもしれない。一見すると、とても単純な問題のようにも思えるが、それではなぜ人はこんな単純な問題に対してミスを犯すのだろう。
ユニットテスト
ここではunittestで認可を検証することを考えることとし、Firestore Security Rulesをテストするたのライブラリ @firebase/rules-unit-testing を使ってテストを書いてみることにした。
テストをかいてみた
まずは簡単なテストをいくつか実装した。
const { initializeTestEnvironment, assertSucceeds, assertFails } =
require('@firebase/rules-unit-testing');
const fs = require('fs');
let testEnv;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: 'demo-testing',
firestore: { rules: fs.readFileSync('firestore.rules', 'utf8') },
});
});
afterAll(async () => {
await testEnv.cleanup();
});
test('同じ組織のusersは読める', async () => {
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await db.doc('members/alice').set({ orgId: 'org-a' });
await db.doc('users/alice').set({ orgId: 'org-a' });
});
const alice = testEnv.authenticatedContext('alice').firestore();
await assertSucceeds(alice.doc('users/alice').get());
});
test('他の組織のusersは読めない', async () => {
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await db.doc('members/alice').set({ orgId: 'org-a' });
await db.doc('users/bob').set({ orgId: 'org-b' });
});
const alice = testEnv.authenticatedContext('alice').firestore();
await assertFails(alice.doc('users/bob').get());
});
test('他の組織のmeetingsが読めてしまう(認可漏れ)', async () => {
await testEnv.withSecurityRulesDisabled(async (context) => {
const db = context.firestore();
await db.doc('members/alice').set({ orgId: 'org-a' });
await db.doc('meetings/m1').set({ orgId: 'org-b' });
});
const alice = testEnv.authenticatedContext('alice').firestore();
await assertFails(alice.doc('meetings/m1').get()); // 修正前のルールではここが失敗する(=読めてしまう)
});まあ、テストは書ける。当然だが、このテストの条件が本当に正しい必要がある。
実際に環境を作って実行してみた
書いただけでは信用できないので、実際に動かしてみることにした。必要なのは firebase-tools と @firebase/rules-unit-testing 、それとテストランナー(ここでは Jest)だ。
npm install -D firebase-tools @firebase/rules-unit-testing jestFirestore Emulator は内部で JVM を使っている。手元の環境には JDK17 しか入っていなかったのだが、firebase-tools はJDK21以上を要求してきた。
Error: firebase-tools no longer supports Java version before 21. Please install a JDK at version 21 or above to get a compatible runtime.
システムには手を入れたくなかったので、作業ディレクトリの中だけにTemurinのJDK21を展開し、そのディレクトリの中だけ JAVA_HOME を差し替えて使うことにした。
curl -L -o jdk21.tar.gz "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.8%2B9/OpenJDK21U-jdk_aarch64_linux_hotspot_21.0.8_9.tar.gz"
mkdir jdk21 && tar xzf jdk21.tar.gz -C jdk21 --strip-components=1
export JAVA_HOME="$(pwd)/jdk21"
export PATH="$JAVA_HOME/bin:$PATH"firebase.json でルールファイルとエミュレータのポートを指定し、=firebase emulators:exec= でエミュレータを起動した状態のままテストを流す。
{
"firestore": {
"rules": "firestore.rules"
},
"emulators": {
"firestore": {
"port": 8080
}
}
}まずは、認可漏れのある方のルール(meetingsがrequest.auth != nullだけのバージョン)で実行してみる。
npx firebase emulators:exec --only firestore "npx jest rules.test.js"
FAIL ./rules.test.js
● 他の組織のmeetingsが読めてしまう(認可漏れ)
Expected request to fail, but it succeeded.
50 |
51 | const alice = testEnv.authenticatedContext('alice').firestore();
> 52 | await assertFails(alice.doc('meetings/m1').get());
| ^
Test Suites: 1 failed, 1 total
Tests: 1 failed, 2 passed, 3 total
想定通り、「他の組織のmeetingsは読めないはず」というテストが落ちた。正確には、拒否されることを期待していたのに、実際には読めてしまった、という失敗のしかたをしている。まさにtl;dvで起きていたとされる状態を、手元で再現できたことになる。
次に、meetingsのルールにもorgIdの確認を追加した修正版に差し替えて、同じテストを実行し直す。
Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total
3件とも通った。1行の条件を追加しただけで、テストが赤から緑に変わることを確認できた。
テストは書けるけど
今回のような単純なケースでさえ、これぐらいのボリュームのテストケースになるとすると、もっと複雑な条件の場合、もっとテストケースは増えそうだ。それらをちゃんと実装し保守していけるものだろうか。しなくてはいけないのだけれど、複雑になればなるほど自信が薄れていく。
コードレビュー
次にコードレビューによって防ぐことを考えてみよう。最近はAI技術が発展したため、コードレビューは人よりもAIの方が得意かもしれない。ただ、すべてAI頼りにはならないため、AIと人、両方の立場からどのようにレビューを進めればいいかを考えることにした。
ここで言う「AIによるレビュー」とは、AIが単独で自動的に自律的にレビューを行うことを指す。一方の「人によるレビュー」とは、人間だけで行うレビューというよりも、人間が主体となり、AIを道具として補助的に使うレビューのことを指す。人だけのレビューは効率が悪すぎるので、もはや取り上げない。
AIエージェントによるレビュー
ここでは実際にAnthropicのモデルSonnet 5とClaudeCodeを使い、コードレビューを実施してみる。コードレビュー用に以下のようなコレクションへのルールを用意した。これは meetings だけ getUserOrgId() での認可確認が漏れている。
match /users/{userId} {
allow read: if request.auth != null
&& resource.data.orgId == getUserOrgId(request.auth.uid);
}
match /chats/{chatId} {
allow read: if request.auth != null
&& resource.data.orgId == getUserOrgId(request.auth.uid);
}
match /transcripts/{transcriptId} {
allow read: if request.auth != null
&& resource.data.orgId == getUserOrgId(request.auth.uid);
}
match /recordings/{recordingId} {
allow read: if request.auth != null
&& resource.data.orgId == getUserOrgId(request.auth.uid);
}
match /meetings/{meetingId} {
allow read: if request.auth != null;
}このルールをAIエージェントにレビューを依頼し、正しく指摘できることを確認する。AIエージェントへの指示は以下とする。
ファイル firestore.rules を読み、コードレビュアーとしてセキュリティ上の問題がないかレビューしてください。
これは Firestore Security Rules のファイルです。マルチテナントの SaaS アプリケーションを想定しており、各コレクションのドキュメントは orgId フィールドで組織(テナント)に紐付いています。
問題があれば具体的に指摘してください。問題が無ければ「問題なし」と報告してください。事前情報は与えませんので、ファイルの中身だけから判断してください。
このレビューを実施した結果、AIエージェントはmeetingsの穴を適切に指摘した。
重大な問題があります。meetings コレクションでテナント分離が完全に抜けています。
他の全コレクション(users, chats, transcripts, recordings)は resource.data.orgId
= getUserOrgId(request.auth.uid) でテナント境界を検証していますが、meetings だけは request.auth !null のみで、認証済みであればどの組織のユーザーでも他組織の meeting ドキュメントを読める状態です。マルチテナント SaaS において典型的なクロステナント情報漏洩の穴です。
コレクションのルールをそれぞれ見比べて、1つだけパターンから外れているものを見つけるというのは、AIが得意そうな作業ではある。また、writeルールが定義されていない点や、同一組織内であれば誰でも任意のドキュメントを読める設計になっている点など、別の観点での指摘も返ってきた。
これはあくまで単純なケースであり、渡された1つのファイルの中で他と矛盾している箇所を見つける、というレビューだった。実際のソフトウェア開発の現場では、そのプロダクトにとってmeetingは本当にorgIdの確認だけで十分か、それとも他の条件があるか、といったビジネス上の意図は分からない。これらは要求仕様やそこにひもづいている設計資料を元にしてレビューをする必要がある。
AIエージェントが設計資料に適切にアクセスできるように、ドキュメントを適切に整備していく必要がありそうだ。
人によるレビュー
設計資料をAIに渡して問題点を予測させるだけなら、それはAIが参照できる資料が増えただけであり、結局はAIによるレビューの一種でしかない。
では人によるレビューは、何を確認する作業なのだろうか。それは、問題を見つけることそのものではなく、人が「これで大丈夫だ」という確信を持つ、その安心感を確認する作業だと思う。AIがどれだけ観点を洗い出し、矛盾を指摘してくれても、最終的に「これで良い」と判断し、その判断に責任を持つのは人だ。AIは確信を持つための材料にはなっても、確信そのものを代わりに持つことはできない。
では、その確信はどう持てばよいのか。僕は、使った時間と実際に調べた内容を明らかにし、できればその作業自体にフィードバックをもらうことが良いと思う。確信は一人で抱いて終わるものではなく、こうして他人に晒し、フィードバックを受けることで、自分だけの感覚から共有できた間隔に変わるのだと思う。
「で、具体的にどうしろってこと?」って声が聞こえてきそうだ。まずは差分を眺め、設計資料を眺め、不安に感じるポイントを箇条書きで書き、それらをAIを駆使して調べていく。どれぐらいの時間を使って調べたのかを明らかにし、そうしてできあがった作業報告書が、人によるレビューのアウトプットとして適切なのだと思う。
シナリオテストによる品質保証
ユニットテストでは、meetingsを1件取り出して決められた条件で読めるか読めないかを確認した。これは、個々のルールが正しいかを検証するには向いているが、実際のアプリケーションがどのように使われているかまでは検証できていない。
ここで「シナリオテスト」と呼ぶ以上、本来はルールという1つのレイヤーだけを狙い撃ちするのではなく、実際のアプリやブラウザを自動操作し、ユーザーが辿る経路そのもので動作の正しさを確認するのが通例であり、効果も高い。理由は単純で、ユーザーの目に実際に触れるのは、ルール単体の判定結果ではなく、アプリを通した最終的な画面や応答だからだ。
これには、ルールだけを見ていては拾えない問題を検出できる、という実際的な理由もある。たとえばFirestoreには、サーバー側の管理コードから使うAdmin SDKがあり、これはSecurity Rulesを一切経由しない。バックエンドのどこかで、Admin SDK経由でmeetingsを読み、その内容を別のAPIやログ、通知機能などから漏らしてしまえば、Security Rulesがどれだけ正しくても意味が無い。ルール単体のテストではこの経路を検証できないが、実際のアプリを操作するシナリオテストであれば、結果として情報が漏れていないかを、経路を問わず確認できる。
具体的には、PlaywrightやCypressのようなブラウザ自動操作のツールを使い、「org-aのユーザーとしてログインし、会議を作成し、同僚を招待する。org-bのユーザーとしてログインし直し、その会議やそこから辿れる情報が一切見えないことを確認する」というように、実際の画面操作を通じてシナリオを再現する。
こうしたシナリオは、思いつきで書くのではなく、実際に想定されるユーザーの操作(ペルソナとジャーニー)を先に洗い出し、それぞれをテストケース化していくと網羅しやすい。誰が、何をして、その後どうなるべきかを一覧にしてから、シナリオテストに落とし込む、という順序だ。
実際にPlaywrightで試してみた
言葉で説明するだけでは実感が湧かないので、簡単なHTMLをでっちあげて実際に動かしてみた。ログインするユーザーを選ぶと、そのユーザーに見える会議の一覧が表示される、という最小限のダミーアプリを2つ用意する。片方はmeetingsをorgIdで絞り込んでいない認可漏れバージョン、もう片方はorgIdで絞り込んでいる修正バージョンだ。
// "サーバー"側の実装(このファイルでは認可漏れバージョン: orgIdで絞り込んでいない)
function fetchMeetings(uid) {
return MEETINGS;
}test('bobにはorg-aの会議が見えてはいけない', async ({ page }) => {
await page.goto(APP_URL);
await page.selectOption('#user', 'bob');
await page.click('#login-btn');
await expect(page.locator('[data-testid="meeting-m1"]')).toHaveCount(0);
});このテストを認可漏れバージョンに対して実行すると、実際にブラウザを操作した結果として失敗する。
✘ bobにはorg-aの会議が見えてはいけない (5.2s)
Error: expect(locator).toHaveCount(expected) failed
Expected: 0
Received: 1
1 failed
1 passed (6.5s)
bobとしてログインした画面に、org-aの会議(meeting-m1)が実際に表示されてしまっている。これはルールの判定結果ではなく、ブラウザに描画された実際の画面から検出した失敗だ。修正バージョンに切り替えて同じテストを実行すると、2件とも通る。
✓ bobにはorg-aの会議が見えてはいけない (162ms) ✓ bobには自分の組織(org-b)の会議は見える (123ms) 2 passed (845ms)
ルール単体のテストにも意味はある。ブラウザ自動操作は実行が遅く、壊れやすい(flaky)ため、全ての観点をそこでカバーしようとすると、CIの実行時間もメンテナンスコストも膨らむ。ルール単体のテストは高速で決定的に動くため、コミットのたびに細かく回すには向いている。両者は代替ではなく、役割の異なる補完関係にあると考えた方がよい。
敵対的検証
他の観点として敵対的検証(adversarial verification)が挙げられる。これは「正しい」ことを直接的に確かめるのではなく、「これは間違っているのではないか」と積極的に反証を試みることで、確からしさを検証する手法だ。
ここまでのユニットテストもシナリオテストも、「想定した条件で、想定した通りに動くか」を確認するものだった。敵対的検証はこれとは向きが逆で、想定していない角度から、意図的にルールを破ろうとする。meetingsのルールで言えば、「orgIdの型が違ったらどうなるか」「membersドキュメントがまだ無い状態で読めてしまわないか」「複数のクエリを組み合わせて、直接は読めないはずの情報を推論できないか」といった、開発者自身では思いつきにくい切り口を、別の視点から探しにいく。
ペネトレーションテストは、この敵対的検証を実際のシステムに対して行うケースの1つだと言える。もっと軽量に、AIエージェントに「このルールを反証してみてほしい」と依頼したり、複数のエージェントに独立して同じルールを攻撃させ、それでも破れなければひとまず信頼する、という形でも敵対的検証は行える。テストが「書いた分しか守らない」のだとすれば、敵対的検証は、まだ書かれていない条件を探しにいく行為だ。
実際に3体のエージェントで反証させてみた
これも実際にやってみた。修正済みのmeetingsのルールと、getUserOrgIdが参照するmembersのreadルールを渡し、「自組織以外のmeetingsは読めない、という主張を反証してほしい」と、独立した3体のエージェントにそれぞれ依頼した。互いの結果は共有していない。
3体とも、ルールそのもの(orgIdの比較条件)を破ることはできなかった。理由として3体が共通して挙げたのは、次のようなFirestoreの性質だった。
- クエリは「フィルタ」ではない。条件を満たさないドキュメントが結果に含まれる可能性があるクエリは、一部だけ除外されるのではなく、クエリ全体が拒否される
- request.authはサーバー側で検証済みの値であり、クライアントから偽装できない
- membersドキュメントが存在しない、型が違う、といった境界条件は、すべて拒否側に倒れる(fail-closed)
その上で、3体とも独立に、同じ懸念点を指摘してきた。「meetingsのルールが安全なのは、membersドキュメントのorgIdが信頼できる値である場合に限られる。ところが、そのmembersへのwriteルールがどこにも示されていない。もし本人が自分のorgIdを自由に書き換えられる設定だったら、meetings側のルールがどれだけ正しくても、テナント分離は完全に破綻する」という指摘だ。
言われてみれば、この記事でここまで書いてきたmeetingsのルール例も、まさにこの状態だった。getUserOrgIdはmembersのorgIdを信頼して使っているのに、membersへの書き込みを制限するルールを一度も示していない。敵対的検証は、狙ったルール単体の穴を見つけられなかった代わりに、その外側にある「暗黙の前提」を暴いた、ということになる。3体が独立に同じ結論に達した、という点も、それぞれが違う攻撃角度を試した上での一致なので、多少なりとも信頼できる材料になる。
そこで、membersのwriteルールも明示しておく。
match /members/{uid} {
allow read: if request.auth != null && request.auth.uid == uid;
allow write: if false;
}orgIdの設定は、クライアントからのwriteを一切禁止し、Cloud Functionsなどの管理コード側からAdmin SDK経由でのみ行うようにする。これで、meetingsのルールが依存していた「membersのorgIdは信頼できる」という前提を、実際に成り立たせたことになる。
Firestore限定の話ではない
ここまでの内容は、Firestore Security Rulesに注目し、Firestoreを前提として話をしてきた。しかし、この問題はFirestoreに限った話ではない。
Firestoreと同じように、クライアント直結型で、認可が「ルール設定」に宿るアーキテクチャは他にもある。代表的なものを上げてみよう。
- Supabase
- Amplify(AppSyncの@auth)
- Hasura
- PostgREST
- Appwrite
- Parse
- GraphQLのフィールド認可
- RESTのIDOR
これらには今回と同様のリスクが潜んでおり、それらを排除するための方法論も、本稿でここまで書いてきた内容が使えるはずだ。実際にいろいろと試してみたかったが時間が取れなかったので、本稿では割愛する。
WHERE句付け忘れはどうか
じゃあ聞くが、いわゆる3層アーキテクチャのように、MySQLやPostgreSQLを使っていて、フロントエンドとデータベースを繋ぐ層としてWeb APIの層がある場合は安全と言えるのだろうか。その場合、確かに今回のようなルールの設定漏れという形の事故は起きなくなるかもしれない。しかし、プログラマーがAPIのハンドラでデータベースへのクエリを書くときに、WHERE句を付け忘れるかもしれない。仕組みは大きく異なるが、認可漏れという状態は同様に発生してしまう。
SQLのWHERE句も、RLSのCREATE POLICYも、FirestoreのallowSecurity Rulesも、突き詰めれば同じものだ。「このレコードを、この発行者に見せてよいか」を判定する述語を、どこかに書く。それだけの話であって、書く場所がアプリケーションのコードか、データベース側のルール設定かが違うに過ぎない。だとすれば、APIサーバーを自作することは、安全性そのものを保証する手段ではない。変わるのは、書き忘れたときの被害範囲(1エンドポイントで済むか、テーブル全体に及ぶか)と、気づきやすさ(通常のコードレビューの目が入るか、入らないか)だけだ。
他のアプローチはないのか
ここまで、ユニットテスト、シナリオテスト、コードレビュー、敵対的検証と、思いつく限りの手を動かしてきた。それでも「見せてよい条件を完全に列挙できたか」という問いに、最終的な答えは出せない。ただし、被害を減らしたり、確信の質を引き上げたりするアプローチは、まだいくつか残っている。
deny by default
まず基本になるのが、拒否をデフォルトにすることだ。書き忘れが起きたときに、それが「情報が漏れる」方向に転ぶか、「単に動かなくなる」方向に転ぶかは、設計で決められる。Firestoreは元々ルールを書かない限り全拒否だが、tl;dvはmeetingsに対して「認証済みなら読める」という許可ルールをわざわざ書いてしまっていた。deny by defaultを徹底するとは、拒否を初期値にするだけでなく、許可を書くときの条件を弱くしすぎないところまでを含む。
このほかにも、確認を書く場所を1コレクション/1テーブルにつき1箇所へ集約する、設定漏れを機械的に検出するCIチェックを入れる(Supabaseのdb lintなど)、個別の具体例ではなく「異なる2つの組織なら常に分離される」といった性質を検証するproperty-based testing、本番相当の環境で継続的に越境アクセスを試み、破られたら即検知するカナリア方式、テナントごとにスキーマやデータベースそのものを分離し、書き忘れが構造的に起こりえないようにする、といった手も考えられる。
形式検証
さらに保証を強めたいなら、形式検証という選択肢もある。AWSはIAMやS3バケットポリシーに対して、SMTソルバーによる自動推論エンジン(Zelkova)を使っている。個別の具体例を1つずつ試すのではなく、「アカウント外からは絶対にアクセスできない」といった性質が、ポリシーの取りうるあらゆる入力について成り立つかどうかを、数学的に証明・反証する。テストが「思いついたシナリオしか守らない」のに対し、形式検証は「書いた性質については、あらゆる入力について成り立つこと」を保証できる。これは本物の進歩だ。
ReBAC
GoogleのZanzibarを起点とするReBAC(関係ベースアクセス制御。OSSではSpiceDBやOpenFGAなど)は、認可をあちこちに散らばったif文ではなく、1つの一貫した関係のモデルに集約するアプローチだ。「誰が、何に対して、どんな関係を持つか」を1箇所で管理することで、書き忘れという失敗の形そのものを減らそうとする。
情報フロー型システム
情報フロー型システム(Denningのラティスモデルを起点とする研究分野で、Jifなどの言語がある)は、テナントAのデータがテナントBに見える場所へ流れ込まないことを、実行時ではなくコンパイル時に、型システムで静的に保証しようとする。型理論は圏論と深く結びついた分野だが、圏論そのものを使った実務的な仕組みについては、確度の高い実例を挙げられない。今のところ、この情報フロー制御が一番近い話だと思う。
つまるところ
ここまで挙げてきたアプローチはいずれも、保証レベルを引き上げはするけれど、問題そのものを消し去るわけではない。たとえば、形式検証は「書いた性質については、あらゆる入力について成り立つこと」を保証できるが、「そもそも何を性質として書くべきか」という一段手前の問いに適切な答えを出すことはできない。証明すべき性質を選ぶのは、結局のところ人間だからだ。
本稿冒頭に書いた「このデータをクエリ発行者に見せてよいか、を考えることは可能なのか」という問いは、状態が固定されれば、データの中身と発行者の認証情報から成るブール式として機械的に評価できるということになる。
しかし、それを上手く人が仕様化できるかは別の問題であり、ここが非常に難しいのだろう。条件は後から増え続け、判定に必要な事実がレコードの外にしかないこともある。個々のレコードは正しく隠せても、複数の許可された行の組み合わせから、隠すべき情報が推測できてしまうこともある。
だから今回取り上げた脆弱性の問題は、特別に不注意な誰かが起こした事故ではないと思う。9個のコレクションでは正しく、1個だけ抜けていたということは、これらの条件を常に完全な状態で列挙し続けるというのが難しい作業ということなのだろう。
また、敵対的検証をしていて実際に見つかった、membersのwriteルールの抜けも、同じ構造をしていた。1つの穴を塞いだつもりでも、その穴が依存している、もう1つ外側の前提には、まだ気づいていないかもしれない。
さらに報告から6ヶ月間、この脆弱性が放置されたことにも、同じ話が繋がっているように思う。穴を塞ぐ技術と、穴を受け止める組織は別物だ。どれだけ良いルールを書き、良いテストを揃えても、それを運用し、報告に応答する人と仕組みが伴っていなければ機能しない。
だから「こうすれば完全に防げる」というものはそもそもなくて、「見せてよい条件を、今どこまで列挙できていて、どこから先はまだ列挙できていないか」を、自分の関わるシステムについて一箇所ずつ確かめ続けることしか、今のところできることはないのだと思う。
まとめ
実際に報告された脆弱性の事例をきっかけに、認証と認可の違いから始まり、実際にFirestoreのルールを再現し、塞ぎ、テストを書き、コードレビューをさせ、シナリオテストと敵対的検証を実際に動かしてみた。手を動かすたびに、「これで安全だ」と思った場所の、さらに外側に前提が見つかる、ということを繰り返した。
この記事自体、書き始めたときに想定していたよりも、ずっと長い道のりになった。それでも、思いつきや伝聞で「気をつけましょう」と書くよりは、実際に手を動かして確かめたことの方が、僕にとってはずっと納得感がある。この記事が、同じようにマルチテナントのシステムを作っている誰かにとって、自分のシステムを一箇所ずつ確かめ直すきっかけになれば良いと思う。