はじめに
サーバーレスのNoSQLドキュメントデータベースとして、Firestoreはとても有力な選択肢かと思います。Firebase Hosting、 Firebase Authenticationなどと合わせて、インフラを持たずに個人でも手軽に利用でき、ちょっとしたモノであれば気軽に公開できる、良い時代になりましたね。
しかしながら、何も考えずにFirestoreを使うのは危険がいっぱいです。間違っても、こんなルールにしてはいけません。
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}
セキュリティルールをちゃんと書かないと危険な理由を3つ挙げてみます。
- 本来権限がないユーザーに秘匿情報を閲覧、改竄されてしまう可能性がある
- 不正なドキュメントを生成され、データを利用するアプリケーションの意図しない動作を招く可能性がある
- 悪意あるユーザーから、大量のドキュメントやサイズの大きいフィールドを作成され、意図しない課金を招く可能性がある
このように、データ漏洩や改竄、意図しないアプリケーションの動作や課金を招かないためにも、必ずセキュリティルールを整理する必要があります。
基本的に、Firestoreのセキュリティルールはホワイトリスト方式なので、何も設定していない最初の状態が一番安全です。セキュリティルールを記述するということは、穴を開けていく行為だということをきちんと認識した上で、必要最低限かつ適切なルールを設定するようにしましょう。
この記事では、上記で挙げた3つの危険から守るための最低限気をつけるべきことをまとめます。なお、こちらの記事と違って業務ではなく趣味で色々とコードを書いている中での内容になりますので、過不足があればマサカリをぜひ。
参照権限が異なる要素を同じドキュメントに格納しない
セキュリティではなくデータモデルのお話ですが、Firestoreは、ドキュメント内の一部のみ取得させる、ということができません。従って、公開情報と非公開情報を同じドキュメントに保持するような設計にしてはいけません。
少し考えると当たり前の話なのですが、私が何も考えずにFirestoreを使い始めたときに見事にやらかして、データモデルごと作り替えるハメになりました。
悪い例
- user
- name
- salary
(salaryが非公開情報かはさておき・・)
良い例
- users
- name
- users_meta
- user_ref
- salary
allow writeは使わない
allow writeは、以下の3オペレーションをまとめて許可することになります。
- create
- update
- delete
そもそも、この記事はちゃんとセキュリティルールを書きましょう、という話なわけですが、果たしてこの3オペレーションがひとまとめになっている状態できちんとセキュリティルールを書けるのでしょうか。難しそうだということは想像に難くないかと思います。
オペレーションごとのresource、request.resource一覧表
少し脱線しますが、度々忘れるのでまとめておこうと思います。
以下表のように、セキュリティルールに利用するresource、request.resourceはオペレーションごとに以下のように設定されるもの、されないものとあるため、writeで一括まとめてセキュリティルールを定義することにはなり得ないはずです。
オペレーション | 現在のドキュメント | 書込ドキュメント | resource(現在) | request.resource(未来) |
---|---|---|---|---|
read | {name: 'tnemotox'} | なし | {name: 'tnemotox'} | undefined |
create | なし | {name: 'tnemotox'} | undefined | {name: 'tnemotox'} |
update | {name: 'tnemotox'} | {age: 31} | {name: 'tnemotox'} | {name: 'tnemotox', age: 31} |
delete | {name: 'tnemotox'} | なし | {name: 'tnemotox'} | undefined |
認証を行う
Firebase Hostingを利用すると、FirestoreにアクセスするためのAPIKeyは公開されます。ですので、その気になれば誰からでも自身のFirestoreにアクセスされてしまうわけです。認証チェックを行わない場合、悪意あるユーザーに対して情報が丸見えの状態ですし、ルールが甘ければデータ改竄までされてしまう可能性があります。きちんと認証チェックするようにしましょう。
Firebase Authenticationと組み合わせると、セキュリティルールでの認証チェックを容易に行うことができます。
service cloud.firestore {
match /databases/{database}/documents {
match /users_meta/{uid} {
allow get: if request.auth != null && request.auth.uid == uid;
}
}
}
認可を行う
Cloud FunctionsからAdmin SDKを利用することで、カスタムクレームと呼ばれる領域に任意の値を設定することができます。ここに認可に必要となる情報を持たせることにより、セキュリティルールで認可を行うことができます。
const functions = require('firebase-functions');
const admin = require('firebase-admin');
exports.selectWorkspace = functions.https.onCall(async (prop, context) => {
// なんやかんや処理した結果、権限を設定する
await admin.auth().setCustomUserClaims(context.auth.uid, {
workspace: {
id: props.workspaceId,
readable: true,
writable: false,
},
});
});
service cloud.firestore {
match /databases/{database}/documents {
match /workspaces/{workspaceId} {
allow get: if request.auth != null
&& request.auth.token.workspace.id == workspaceId
&& request.auth.token.workspace.readable;
allow create: if request.auth != null
&& request.auth.token.workspace.id == workspaceId
&& request.auth.token.workspace.writable;
}
}
}
なお、tokenを利用してパスやresourceと紐付けたルールはlistには適用できません。Firestoreを呼び出すクエリにwhere句を付与していない場合、セキュリティルールにおけるチェック処理ではresourceやパスに含まれるドキュメントIDを一意に特定することができないため、undefinedとなるため注意が必要です。
スキーマの検証
認証チェックをすることで、悪意あるユーザーからのデータ取得、改竄は防ぐことができるようになりました。しかし、冒頭危険な理由で挙げた不正なドキュメント作成は解決していません。
正規の手順を踏んで利用登録したユーザーが、悪意を持って不正なデータ構造のドキュメントを作成、更新した場合、認証チェックだけでは防ぐ手立てはないためアプリケーションの意図しない動作を招く可能性があります。また、不正に長い文字列を設定された場合、意図しない課金を招きます。
そうならないためにも、きちんとスキーマの検証を行いましょう。
検証すべきポイント
前述のオペレーションごとの一覧表にまとめた通り、request.resourceがデータ書込後の姿ですので、これに対して検証をする必要があります。検証する要素は色々考えられるとは思いますが、個人的には最低限以下の3つをチェックしておくべきと考えています。
- フィールド名(必須、任意)
- フィールドの型
- 最大桁数
少なくともこれらがきちんと定まっていれば、不正なデータを挿入することはできないかと思っていますが、漏れがあったら教えてください。。
具体例をみていきましょう。
service cloud.firestore {
match /databases/{database}/documents {
match /tasks/{taskId} {
allow create: if request.auth != null
&& validateTask(request.resource.data);
}
// ドキュメントの検証
function validateTask(data) {
return hasRequiresFields(data)
&& notHasUndefinedFields(data)
&& data.createdAt is timestamp
&& data.updatedAt is timestamp && data.updatedAt == request.time
&& data.ticketNumber is number
&& data.name is string && data.name.size() <= 100 && data.name.size() >= 1
&& data.done is bool
&& data.description is string && data.description.size() <= 1000;
}
// 必須フィールドが全て存在しているかチェック
function hasRequiresFields(data) {
return data.keys().hasAll(['name', 'done', 'createdAt', 'updatedAt']);
}
// 未定義のフィールドが存在していないかチェック
function notHasUndefinedFields(data) {
return data.keys().hasOnly(['name', 'done', 'description', 'createdAt', 'updatedAt']);
}
}
}
- hasRequiresFieldsで必須フィールドをチェック
- notHasUndefinedFieldsで未定義のフィールドが存在していないかをチェック
- 各フィールドごとにフィールドの型と最大桁数をチェック
と言った具合になっています。
これくらいであれば、さほど手間もかかりませんね。
認証チェックに加えてスキーマの検証をすることで、最初に挙げた3つの危険な理由を排除することができましたね。この記事には書いていませんが、作成したセキュリティルールは必ずテストをするようにしましょう。特に、認証認可、スキーマのチェックはやっておくべきでしょう。
MapやArrayなど要素数が不定な型の検証
Firestoreを使いはじめの頃、愚かな私はこう思いました。
ドキュメント数を増やすと課金対象も増えるし、1ドキュメントにでっかいMapを持たせれば、1回のreadで必要な情報全部取得できるし、楽チンで良いじゃん!
しかしながら、Firestoreのセキュリティルールではfor文などを利用することができないため、要素数が不定な型を検証することができません。Cloud Functionsを介した操作でない限りは、MapやArrayを利用することは避けるべきでしょう。
要素数が決まっていないMapやListに対して力技でセキュリティルールを記述するとこんな感じになります。カオスですね。但し、セキュリティルールはリクエストあたりの最大式評価数が1000と定められているため、この方法でも無限にチェックを記述することができるわけではありません。
function validateItems(items) {
return items is list
&& items.size() <= 10
&& (items.size() < 1 || validateItem(items[0]))
&& (items.size() < 2 || validateItem(items[1]))
(snip)
&& (items.size() < 10 || validateItem(items[9]));
}
余談ですが、Firestoreの1ドキュメントが保持できるデータ量は1 MiB(1,048,576 バイト)ですので、そもそも1ドキュメントにでっかいMapを持たせるというのはセキュリティ以外にも問題ありありです。
まとめ
セキュリティルールをちゃんと書かないと
- 本来権限がないユーザーに秘匿情報を閲覧、改竄されてしまう可能性がある
- 不正なドキュメントを生成され、データを利用するアプリケーションの意図しない動作を招く可能性がある
- 悪意あるユーザーから、大量のドキュメントやサイズの大きいフィールドを作成され、意図しない課金を招く可能性がある
と言った危険があるので、
- 参照権限が異なる要素はきちんとドキュメントを分けること
- 認証、認可チェックを行うこと
- スキーマの検証を行うこと
を最低限意識するようにしましょう。
余談
書き始めるまでは面倒なんですけど、書き始めるとスラスラと筆が進みますよね、こういうの。
最近は、業務でコードを書くことがなくなり、社内外政治・パワポエクセル・メール・見積・非機能要件・ベンダコントロールおじさんなので、家でコードを書いたりアウトプットしたりする時間を大切にしていきたいものですね。