はじめに
こんにちは。株式会社Flatt Securityセキュリティエンジニアの梅内(@Sz4rny)です。
本稿では、弊社がこれまでに実施してきたFirebase診断の事例や筆者独自の調査をもとに、Firebaseを活用して開発されたサービスにおいて発生しやすい脆弱性の概要やそれにより引き起こされるリスクおよびその対策を深刻度や発生頻度の評価を踏まえつつお伝えします。本稿を通じて、Firebaseを活用したサービスにおいて発生しやすい脆弱性にはどのようなものがあるのか、また、そのような脆弱性を埋め込むことなくセキュアなサービス実装を実現するためにはどのような観点に気をつければよいのかについて理解を深めていただけますと幸いです。
なお、本稿では「Firebase活用時に限って発生しうる脆弱性(例:Firestoreのセキュリティルールにおけるバリデーション不備)」と「Firebaseを活用しないサービスにおいても発生しうる脆弱性(例:メールアドレス所有の検証不備)」を区別せずに記載します。これは、Firebaseを活用したサービスの開発に関与するすべての方にとって、埋め込まれがちな脆弱性がFirebase特有のものなのかそうでないかという分類はあまり意味をなさないと考えているからです。筆者は、このような分類を超えて、Firebaseを活用したサービスのセキュリティを俯瞰的に捉えることによって初めてセキュアな開発が実現できるものと考えています。
ところで、本ブログでは過去にFirebaseセキュリティの基礎やFirestoreにおけるセキュリティルール実装の際のベストプラクティス、Firebase Authentication利用時の注意点等に関するブログを多数公開しています。本稿と合わせてぜひご参照ください。
また、Flatt Securityのセキュリティ診断(脆弱性診断)ではFirebaseを用いたサーバーレスなアプリケーションも診断可能です。 お客様の事例インタビューも公開しておりますので、ぜひご覧ください。
- はじめに
- 評価指標
- 脆弱性一覧
- No.1 メールアドレス所有の検証不備
- No.2 自己サインアップの制限不備
- No.3 想定外の認証プロバイダを用いたログインが可能
- No.4 メールアドレス登録状況の漏洩
- No.5 パスワードポリシーの不備
- No.6 認証状態の検証不備
- No.7 サービス間におけるアクセス制御ポリシーの不一致
- No.8 Firestoreのセキュリティルールにおけるドキュメントのスキーマ検証不備
- No.9 Firestoreのセキュリティルールにおけるフィールド値の検証不備
- No.10 Cloud Storageのセキュリティルールにおけるアップロード制限の不備
- おわりに
評価指標
本稿では、それぞれの脆弱性を説明する際に、評価指標として「深刻度」および「発生頻度」をあわせて示します。これらの指標は以下を表しているものとします。
- 深刻度: その脆弱性を突いた攻撃が実際に成立した場合に起こりうる被害や影響の大きさ。
- 高: サービスにセキュリティ上の重大な影響を及ぼしうるもの。
- 中: 部分的な情報漏洩やデータの改ざんなど、サービスにセキュリティ上の影響を及ぼしうるもの。
- 低: サービスにバグを引き起こしたり、データに不整合をもたらしたりするもの。あるいは、サービスに対する潜在的な脆弱性となりうるもの。
- 発生頻度: その脆弱性が存在する度合い。
- 高: 比較的多くのサービスにおいて見かけるもの(50%-)。
- 中: 一部のサービスにおいて見かけるもの(10%-50%程度)。
- 低: ごくまれに見かけるもの(-10%)。
なお、上記の評価指標および各脆弱性の評価は筆者が定義した本記事のみで利用する尺度であり、弊社の脆弱性診断等で用いるものとは異なりますので、参考程度にとどめてください。
脆弱性一覧
本稿で紹介する脆弱性の一覧およびその評価は以下の通りです。
No. | 深刻度 | 発生頻度 | 脆弱性 | 関連するサービス |
---|---|---|---|---|
1. | 中 | 高 | メールアドレス所有の検証不備 | Firebase Authentication |
2. | 中 | 低 | 自己サインアップの制限不備 | Firebase Authentication |
3. | 低-高 | 低 | 想定外の認証プロバイダを用いたログインが可能 | Firebase Authentication |
4. | 低-中 | 中 | メールアドレスの登録確認が可能 | Firebase Authentication |
5. | 低 | 中 | パスワードポリシーの不備 | Firebase Authentication |
6. | 高 | 低 | 認証状態の検証不備 | すべてのサービス |
7. | 中-高 | 中 | サービス間におけるアクセス制御ポリシーの不一致 | すべてのサービス |
8. | 低-中 | 高 | Firestoreのセキュリティルールにおけるドキュメントのスキーマ検証不備 | Firestore |
9. | 低-高 | 高 | Firestoreのセキュリティルールにおけるフィールド値の検証不備 | Firestore |
10. | 中 | 中 | Cloud Storageのセキュリティルールにおけるアップロード制限の不備 | Cloud Storage |
次章以降では、各脆弱性についてその概要やリスク、対策について詳しく説明します。
No.1 メールアドレス所有の検証不備
- 深刻度: 中
- 発生頻度: 高
概要
Firebase Authenticationでは、認証方式としてメールアドレスおよびパスワードを用いた認証(以下、パスワード認証)を利用することができます。
このパスワード認証において、入力されたメールアドレスを入力したユーザが所有していることを確認していない場合、脆弱性につながるおそれがあります。詳細はリスクの節にて説明します。
リスク
大きく分けて、以下の2つのリスクが想定されます。
- 想定されていないユーザによるサービスへのアクセス
- 攻撃者によるアカウントの乗っ取り
まず、前者のリスクについて説明します。例えば、ある企業の社内システムにおいて想定外のユーザによるアクセスを防ぐために、メールアドレスのドメインをもとにアクセス制御を行っているとします。
この場合、Firestoreのセキュリティルールは以下のように定義されているでしょう。
match /secrets/{docId} { allow read: if ( request.auth != null && request.auth.token.email.matches("^.+@example[.]com$") ); }
上記のセキュリティルールによって、secrets
コレクション内のドキュメントにアクセスできるユーザはドメインがexample.com
であるメールアドレスを持つユーザに限られるように見えます。
しかし、このセキュリティルールではアクセス元のユーザが認証に利用したメールアドレス(request.auth.token.email
にバインドされる文字列)を当該ユーザが所有しているか検証されていません。そのため、たとえば[email protected]
のようなアクセス元ユーザが所有していない適当なメールアドレスでアカウント登録が行われた場合、secrets
コレクション内のドキュメントに自由にアクセスできてしまいます。
次に、後者のリスクについて説明します。こちらのパターンは、以下のフローが再現されると攻撃者によるアカウントの乗っ取りが達成されるというものです。
- 被害者がアカウント登録の際に自身のメールアドレスを誤入力する。
- 誤入力されたメールアドレスを所有している人(以下、攻撃者)が手順1の登録の際に送信されたアカウント登録確認メール等で自身のメールアドレスが登録されたことを知る。
- 被害者がサービス上で様々なアクション(プロフィール情報の登録、情報の投稿等)を行う。
- 攻撃者はサービス上に存在するパスワード再設定機能を用いて手順1で登録されたアカウントのパスワードを再設定する。このとき、当該アカウントに紐づくメールアドレスは攻撃者が所有するものなので、攻撃者はパスワード再設定用のリンク等が記載されたメールを受信できる。
- 攻撃者はパスワードを再設定し、自身のメールアドレスと新しいパスワードでログインを行う。
この手順によるアカウントの乗っ取りを実現するためには「被害者がメールアドレスを誤入力すること」、「誤入力されたメールアドレスを攻撃者が所有していること」、「サービスにパスワード再設定機能が存在すること」等の条件が満たされている必要があるため攻撃が成立する可能性はかなり低いですが、それでもゼロではありません。
対策
以下に示す対策を実施することが有効です。
- アカウント登録時に確認用のメールを送信する
- セキュリティルールやCloud Functions関数においてメールアドレスの所有状況を検証する
前者について、FirebaseではsendEmailVerification
関数を用いて確認用のメールを送信することが可能です。メール内にはリンクが含まれており、そのリンクにアクセスすることで当該メールアドレスを所有していることが確認されます。
ただし、メールアドレスの所有権を確認するだけでは不十分で、各サービスにおいて「メールアドレスの所有権が確認されていればアクションを許可する」といったアクセス制御を行うことが必要です。これが後者に挙げている対策に該当します。
たとえば、FirestoreやCloud Storageのセキュリティルールでは、メールアドレスの所有確認が済んでいる場合に限ってrequest.auth.token.email_verified
がtrue
と評価されます。そのため、前述のセキュリティルールは以下のように改修できます。
match /secrets/{docId} { allow read: if ( request.auth != null && request.auth.token.email.matches("^.+@example[.]com$") && request.auth.token.email_verified // この行を追加 ); }
また、Cloud Functionsでは、UserRecord
クラスのemailVerified
プロパティを参照することで確認が完了しているか判定できます。そのため、関数の実装は以下のようになるでしょう。
import * as functions from "firebase-functions"; import { getAuth } from "firebase-admin/auth"; export const func = functions.https.onCall(async (data, context) => { // 認証状態のチェック // ... // メールアドレス所有のチェック const user = await getAuth().getUser(context.auth.uid); if (!user.emailVerified) { return new functions.https.HttpsError("permission-denied", "..."); } // 本来の処理 // ... });
No.2 自己サインアップの制限不備
- 深刻度: 中
- 発生頻度: 低
概要
自己サインアップとは、本来のユーザ登録フローを経由せずに、ユーザがFirebase AuthenticationのAPIと直接やり取りすることでユーザ登録を行うことを指します。Firebase Authenticationでは、createUserWithEmailAndPassword
関数を呼び出すことで自己サインアップを実行することができます。以下にサンプルコードを示します。
import { initializeApp } from "firebase/app"; import { createUserWithEmailAndPassword, getAuth } from "firebase/auth"; const app = initializeApp({ ... }); const auth = getAuth(app); const email = "..."; const password = "..."; createUserWithEmailAndPassword(auth, email, password) .then((userCredential) => { ... }) .catch((e) => { ... });
自己サインアップは、ユーザ自身によるユーザ登録が想定されているようなサービスでは問題になりません。一方で、たとえば管理者が指定したユーザに限ってユーザ登録を想定しているようなサービスにおいて、自己サインアップは脆弱性になり得ます。
リスク
前述した「限られたユーザのみの登録が想定されているサービス」において自己サインアップが可能である場合、当該サービスに想定外のユーザがアクセス可能になるおそれがあります。
たとえば、社内の限られた社員のみがアクセスする社内システムを想定します。このシステムでは、管理者が指定した社員のみがユーザ登録を行えるものとします。このシステムがFirestoreをデータストアとして利用している場合、「そもそもユーザ登録されていれば許可された社員である」という前提があるため、セキュリティルールは以下のように実装されるでしょう。
match /secrets/{docId} { allow read: if request.auth != null; }
このような状況において自己サインアップが可能な場合、管理者が想定していない社員までもが当該システムにアクセス可能になるおそれがあります。
さらに悪いことに、当該システムが利用するFirebaseのAPIキー(ここではinitializeApp関数に渡すコンフィグを指します)がインターネット上からアクセス可能だった場合、任意の者が当該システム内の情報に自由にアクセスできてしまうため、大規模な情報漏洩に発展するおそれがあります。
このような状況の例として、以下のようなケースが考えられます。
- サービスがFirebase Hostingでホストされており、
https://<ドメイン名>/__/firebase/init.js
にアクセスすることでAPIキーが取得できる場合- Firebase Hostingを利用している場合、ドメイン名はデフォルトで
<プロジェクト名>.web.app
か<プロジェクト名>.firebaseapp.com
になるため、比較的推測が容易です。
- Firebase Hostingを利用している場合、ドメイン名はデフォルトで
- APIキーがインターネット上からアクセス可能なリポジトリや静的ファイルに格納されている場合
なお、FirebaseのAPIキーは機密情報ではないため、APIキーが取得されたとしてもなお安全であると言えるような対策を行う必要があります。
対策
以下に示す対策を実施することが有効です。
- Identity Platformと連携し、自己サインアップを拒否する手法
- カスタムクレームを利用する手法
前者の手法は、Firebase AuthenticationをIdentity Platformと連携するプランにアップグレードした上で、設定画面の「ユーザーアクション」のメニュー内に存在する「作成(登録)を許可する」というオプションを無効化するというものです(ドキュメント)。
後者の手法は、BaseAuth.setCustomUserClaims
メソッドを利用してカスタムクレームを付与するというものです。
たとえば、正規のユーザ作成フローにおいて、以下のようにカスタムクレームを付与するロジックを実装しておけば、正規のフローを介したユーザとそうではない(すなわち、自己サインアップした)ユーザを識別できるようになります。このカスタムクレームはAdmin SDK経由でのみ編集できるため、Admin SDKを利用できない一般ユーザからは編集できません。
import { getAuth } from "firebase-admin/auth"; // 略 getAuth().setCustomUserClaims(uid, { isLegitimateUser: true, });
なお、FirestoreやCloud Functionsなど、Firebase Authenticationの認証情報を利用する全てのサービスにおいて、このカスタムクレームを検証するロジックを実装する必要があります。このロジックが欠落していた場合、後述する「サービス間におけるアクセス制御ポリシーの不一致」に繋がってしまいます。
match /secrets/{docId} { allow read: if ( request.auth != null && request.auth.token.isLegitimateUser // 正当なユーザを示すカスタムクレームの検証 ); }
import * as functions from "firebase-functions"; import { getAuth } from "firebase-admin/auth"; export const func = functions.https.onCall(async (data, context) => { // 認証状態のチェック // ... const user = await getAuth().getUser(context.auth.uid); if (!user.customClaims.isLegitimateUser) { return new functions.https.HttpsError("permission-denied", "..."); } // 本来の処理 // ... });
まとめると、単純に自己サインアップを拒否するだけであれば前者の手法が推奨されます。一方で、自己サインアップの拒否に加えて、より細かい粒度での権限管理(例: ロールベースのアクセス制御)を行いたい場合は、カスタムクレームを用いる方式が適しています。
No.3 想定外の認証プロバイダを用いたログインが可能
- 深刻度: 低-高
- 発生頻度: 低
概要
Firebase Authenticationでは、あるプロジェクトにおいて認証に利用できるプロバイダを選択することができます。プロバイダには、たとえば、以下のようなオプションが存在します。
- メールアドレスおよびパスワード
- 電話番号
- GitHub
- OpenID Connect
- 匿名認証
複数の認証手段を提供することはユーザの利便性を高める上で有効ですが、以下のような場合に脆弱性に繋がるおそれがあります。
- 認証プロバイダを機能や目的によって使い分けている場合
- 開発者が想定していない認証プロバイダが有効化されている場合
まず、前者のケースについて説明します。
たとえば、あるサービスでは一般ユーザ向けおよび開発者ユーザ向けの機能を以下のポリシーに従って提供しているとします。
- 一般ユーザ
- 認証プロバイダとしてGoogleを利用する
- Firestoreの読み取りは
public
コレクションのドキュメントに限って可能
- 開発者ユーザ
- 認証プロバイダとしてGitHubを利用する
- Firestoreの読み取りは
public
コレクションおよびdeveloper
コレクションのドキュメントに限って可能
この場合、Firestoreのセキュリティルールは以下のように実装する必要があります。
match /public/{docId} { allow read: if ( request.auth != null && request.auth.token.firebase.sign_in_provider in ['google.com', 'github.com'] ); } match /developer/{docId} { allow read: if ( request.auth != null && request.auth.token.firebase.sign_in_provider in ['github.com'] ); }
ここで、認証プロバイダを検証するロジックを誤って同じ関数に集約してまうと、想定外のアクセス(この場合は、一般ユーザによるdeveloper
コレクションへのアクセス)が可能になってしまいます。
function isValidSignInProvider(provider) { return provider in ['google.com', 'github.com']; } match /public/{docId} { allow read: if ( request.auth != null && isValidSignInProvider(request.auth.token.firebase.sign_in_provider) ); } match /developer/{docId} { allow read: if ( request.auth != null && isValidSignInProvider(request.auth.token.firebase.sign_in_provider) // ! ); }
次に、後者のケースについて説明します。
たとえば、あるサービスでは認証プロバイダとしてGoogleのみを利用していたとします。この場合、ログイン済みであればその認証プロバイダはGoogleに限定されるので、Firestoreに機密性の高い情報を格納する場合でもアクセス元ユーザの認証プロバイダは特段検証する必要はないように思えます。
// 認証済みユーザのみがアクセス可能なコレクション match /secrets/{docId} { allow read: if ( request.auth != null // 認証情報が存在する時点でGoogleを利用したログインなので検証しなくても良い(?) // request.auth.token.firebase.sign_in_provider == 'google.com' ); }
さて、このサービスに仕様変更が発生し、公開情報にのみアクセス可能な匿名認証を用いたゲストユーザ機能が実装されたとします。この場合、ゲストユーザは前述のsecrets
コレクションにアクセスできてはならないはずですが、request.auth.token.firebase.sign_in_provider
の値がgoogle.com
であること(anonymous
ではないこと)が検証されていないため、アクセスできてしまいます。
リスク
想定外のユーザによるサービスへのアクセスが可能になるおそれがあります。
本脆弱性によるリスクは、特に認証プロバイダを機能や目的によって使い分けている場合に顕在化すると言えます。
対策
Firebase Authenticationにおける認証状態を確認する必要がある全てのサービス(FirestoreやCloud Storageのセキュリティルール、Cloud Functions関数等)において、アクセス元ユーザの認証プロバイダが想定されたものであるかどうかを検証するようにしてください。特に、匿名認証を許可している場合、匿名認証によってログインしたユーザに対する過剰なアクセス権限が付与されないように注意してください。
なお、FirestoreやCloud Storageのセキュリティルールにおいて、認証プロバイダは前述の通りrequest.auth.token.firebase.sign_in_provider
にてアクセスできます。まとめると、認証プロバイダの検証をセキュアに行うためには、以下の方針に従うと良いでしょう。
- 想定される認証プロバイダはallowlist形式で明示的に許可する
- 認証プロバイダが単一の場合は
request.auth.token.firebase.sign_in_provider == '<Provider ID>'
- 認証プロバイダが複数の場合は
request.auth.token.firebase.sign_in_provider in ['<Provider ID>', ...]
- 認証プロバイダが単一の場合は
- (将来的なものも加えて)許可が想定されない認証プロバイダはdenylist形式で明示的に拒否しておく
- 匿名認証を拒否する場合は
request.auth.token.firebase.sign_in_provider != 'anonymous'
- 匿名認証を拒否する場合は
No.4 メールアドレス登録状況の漏洩
- 深刻度: 低-中
- 発生頻度: 中
概要
Firebaseでは、メールアドレスを引数にfetchSignInMethodsForEmail
関数を呼び出すことで、特定のメールアドレスに紐づいた認証プロバイダの一覧を取得することが可能です。
この呼び出しの返り値はPromise<string[]>
型であり、Promise
にラップされた配列の要素数が0でなければ当該メールアドレスに何らかの認証プロバイダが紐づいていると判断できます。すなわち、当該メールアドレスがそのサービスに登録されていることが確認できます。
たとえば、下記のコードにおけるinitializeApp
関数の引数として渡したコンフィグに対応するFirebaseプロジェクトにて、パスワード認証を利用している[email protected]
というメールアドレスのアカウントが存在するとします。
// check.js import { initializeApp } from "firebase/app"; import { fetchSignInMethodsForEmail, getAuth } from "firebase/auth"; initializeApp({ ... }); const auth = getAuth(); const email = "[email protected]"; fetchSignInMethodsForEmail(auth, email).then((result) => { console.log(result); });
このコードを実行すると、パスワード認証を利用していることが確認できます。
$ node check.js [ 'password' ]
リスク
特定のメールアドレスがサービスに登録されているか確認することが可能です。このリスクは、登録状況が判明すると利用者のプライバシーに悪影響が生じるようなサービスにおいて特に高まります。
また、この確認をある程度まとまった数のメールアドレスに対して実施することで、Firebaseが提供するレートリミットの範囲内で部分的なアカウント列挙が可能になるおそれがあります。アカウント列挙により存在が特定されたアカウントの情報は、パスワードリスト攻撃やフィッシングなどの将来的な攻撃に向けた足がかりとして利用されるおそれがあります。
対策
Identity Platformが提供しているメールの列挙保護機能(ドキュメント)を用いることで対策が可能です。
公式ドキュメントの手順に従ってメールの列挙保護機能を有効化すれば、fetchSignInMethodsForEmail
関数を使ってもメールアドレスの登録状況を確認することが不可能になります。
具体的には、概要の節で実行したコードを同条件で実行しても、返り値は常に空の配列になります。
$ node check.js []
No.5 パスワードポリシーの不備
- 深刻度: 低
- 発生頻度: 中
概要
Firebase Authenticationを用いてパスワード認証を提供する場合、ユーザが総当たり攻撃や辞書攻撃等の影響を受けないようにする必要があります。そのためには、ユーザによって入力されたパスワードが前述の攻撃に対して十分な強度を持つかどうかを決定するポリシーをサービス側で事前に定義しておき、そのポリシーに反するようなパスワードを指定できないようにすることが好ましいと言えます。
たとえば、OWASP ASVS 4.0 の V2.1 Password Securityにおいては、パスワード構成要件として以下に示す項目を満たすことが望ましいと定義されています(ハイフン以降は筆者が追記した訳文)。
- 2.1.1 Verify that user set passwords are at least 12 characters in length (after multiple spaces are combined). - (連続する空白を結合した後の)パスワードの長さが最低でも12文字である。
- 2.1.2 Verify that passwords of at least 64 characters are permitted, and that passwords of more than 128 characters are denied. - 最低でも64文字の長さを持つパスワードが許容されること、また、128文字より長いパスワードは拒否されること。
- ...
- 2.1.7 Verify that passwords submitted ... are checked against a set of breached passwords ... If the password is breached, the application must require the user to set a new non-breached password. - 入力されたパスワードが侵害されたパスワードと照合されており、もし入力されたパスワードの侵害が確認された場合、ユーザに侵害されていない新しいパスワードを設定するよう要求しなければならない。
- ...
これらの背景を考慮すると、Firebase Authenticationのパスワード認証を利用するサービスにおいては、下記2点の要件が満たされていることが望ましいと言えます。
- 標準等を参考にしつつ、堅牢なパスワードポリシーを定義する。
- 策定したパスワードポリシーに準拠したパスワードのみが利用可能であることを、ユーザによるパスワードの設定が可能な箇所(アカウント作成画面、パスワード変更画面、パスワード再設定画面等)において確認する。
さて、Firebase Authenticationでは、6文字未満のパスワードに対してはFirebaseAuthWeakPasswordException
という例外がスローされることがドキュメントに記載されていますが、これ以外のポリシーは存在しません。
また、2023年6月現在、Identity Platformにおいてサービスごとにパスワードポリシーを定義できる機能(参照)がpre-GAとして提供されていますが、こちらの機能についても以下の課題があります。
- pre-GA版のため、事前の告知なく機能が変更されたり、SLA が担保されていなかったりする(参考: Service Specific Terms のPre-GA Offerings Terms)。
- 機能を利用するには個別にプレビュー版へのアクセスをリクエストする必要がある。
- 文字種および文字列長に関する制約しか定義できない。
- たとえば「パスワードが過去に侵害されていないか検証すること」をポリシー上で定義できない
そのため、前述の要件を満たすためには、ユーザがパスワードを入力するフロントエンドのフォームやコンポーネント、あるいは、入力されたパスワードを取り扱うCloud Functionsにおいて、受け付けたパスワードがポリシーに準拠しているかを検証するロジックを適切に実装する必要があります。
リスク
総当り攻撃や辞書攻撃に対する耐性を持たないパスワードが設定されることで、アカウントが第三者に乗っ取られるおそれがあります。
対策
概要のにて示した2つの要件が満たされるように、パスワードポリシーの定義とそれを用いた検証ロジックの実装を行うことが有効です。
なお、Identity Platformのパスワードポリシー管理機能がGAとなった後はそちらにシフトしていくことが有効だと考えられますが、引き続きフロントエンドにおいて入力されたパスワードをバリデーションするロジックが実装されている方がUX等の観点から望ましいと言えます。
No.6 認証状態の検証不備
- 深刻度: 高
- 発生頻度: 低
概要
No.1-5ではFirebase Authenticationに関連する脆弱性について説明しましたが、たとえこれらの脆弱性やリスクについて理解し対策を実施していたとしても、各サービスにおいて認証状態を適切に検証していなければ重大なリスクに繋がるおそれがあります。
たとえば、以下のFirestoreのセキュリティルールがあるとします。
match /secrets/{docId} { allow read; allow write; }
このセキュリティルールでは、認証状態を一切チェックすること無く、secrets
コレクション内のドキュメントの読み取りや新規作成、編集、削除が許可されています。
このように認証状態がチェックされていない場合、たとえフロントエンドにおいて堅牢なログイン用のロジックを実装していたとしても、また、Firebase Authenticationにおいて堅牢な認証ポリシーを構成していたとしても、クライアントライブラリ等を用いれば認証無しに直接Firebase上のサービスにアクセスすることが可能です。
本脆弱性は、任意のユーザによる任意の操作が許可されるおそれがあるという観点において、本稿で紹介する中でも特に深刻度の高い脆弱性です。
リスク
適切な認証を経ることなしにFirestore上のデータの読み取りや改ざん、および、Cloud Functions関数の呼び出し等が可能になるおそれがあります。
対策
No.1-5で紹介した認証ポリシーの堅牢化に加えて、Firebase上の各サービスにおいて必ず認証状態を検証するようにしてください。
以下は、パスワード認証によってログイン済みのユーザに限ってsecrets
コレクション内のドキュメントへのアクセスを許可する堅牢なFirestoreのセキュリティルールの例です。
// 認証状態の検証を行う関数 function checkAuthenticationStatus(auth) { return ( // 認証有無のチェック auth != null && // 認証プロバイダのチェック auth.token.firebase.sign_in_provider == 'password' && // (必要であれば)利用しない認証プロバイダの明示的な拒否 auth.token.firebase.sign_in_provider != 'anonymous' && // (必要であれば)カスタムクレームのチェック auth.token.isLegitimateUser && // メールアドレスの所有確認 auth.token.email_verified && // (必要であれば)メールアドレスのドメインのチェック auth.token.email.matches('^.+@example[.]com$') ); } match /secrets/{docId} { allow read: if checkAuthenticationStatus(request.auth); }
また、上記のセキュリティルールにおけるcheckAuthenticationStatus
関数と同様のロジックをCloud Functions上で実装する際の例を以下に示します。
import * as functions from "firebase-functions"; import { getAuth } from "firebase-admin/auth"; const checkAuthenticationStatus = async (auth) => { // 認証有無のチェック if (!auth) { return new functions.https.HttpsError("permission-denied", "..."); } const user = await getAuth().getUser(auth.uid); // 認証プロバイダのチェック if ( user.providerData.length !== 1 || user.providerData[0].providerId !== "password" ) { return new functions.https.HttpsError("permission-denied", "..."); } // (必要であれば)利用しない認証プロバイダの明示的な拒否 if (user.providerData.map((d) => d.providerId).includes("anonymous")) { return new functions.https.HttpsError("permission-denied", "..."); } // (必要であれば)カスタムクレームのチェック if (!user.customClaims.isLegitimateUser) { return new functions.https.HttpsError("permission-denied", "..."); } // メールアドレスの所有確認 if (!user.emailVerified) { return new functions.https.HttpsError("permission-denied", "..."); } // (必要であれば)メールアドレスのドメインのチェック if (!user.email.match(/^.+@flatt[.]tech$/)) { return new functions.https.HttpsError("permission-denied", "..."); } };
No.7 サービス間におけるアクセス制御ポリシーの不一致
- 深刻度: 中-高
- 発生頻度: 中
概要
Firebase上の各種サービスを利用する場合、FirestoreやCloud Storageにおいてはセキュリティルールを用いて、また、Cloud Functionsにおいては関数の実装に利用している言語を用いて、アクセス制御要件を満たすようにルールやロジックを実装する必要があります。
そのような状況において、仕様変更等によってアクセス制御要件が変わった際は、関連する全てのサービスにおいて新たな要件を満たすように実装を修正する必要があります。その際、あるサービスのアクセス制御ロジックの修正を何らかの理由により忘れてしまった場合、サービス間でアクセス制御のレベルに差異が生じてしまいます。
ここで、「あるサービスのアクセス制御ロジックが本来の要件よりも厳しい」場合は「本来利用できるはずの機能が利用できない」という問題に留まります。一方で、「あるサービスのアクセス制御ロジックが本来の要件よりも緩い」場合は、とりもなおさずセキュリティ上の脆弱性に繋がります。
このケースに該当する事例として、以下のようなものが挙げられます。
- 正当なユーザを示すカスタムクレームをユーザ作成時に付与しており、Firestoreのセキュリティルールではそれを検証していた。一方で、Cloud Storageのセキュリティルールにおいて検証が行われておらず、カスタムクレームが付与されていないユーザもストレージ上のファイルにアクセス可能だった。
- サービス内の一部の機能のみにアクセス可能なゲストとしてログインする機能を導入する際に匿名認証を許可した。その際、FirestoreおよびCloud Storageのセキュリティルールにおいて、ゲストによるアクセスを許可しないコレクションに対して認証プロバイダが
anonymous
ではないことを検証する条件式を追加していた。一方で、Cloud Functions関数において認証プロバイダの検証が欠落しており、ゲストであっても一般ユーザと同様に関数を呼び出せるようになっていた。
リスク
仕様上はアクセス権限を持たないユーザによるデータの操作や関数の呼び出しが可能になるおそれがあります。
対策
本脆弱性に対しては「Firebaseのこの設定項目をONにする」といった単純な対策は存在せず、Firebase上の各種サービスにおいて同一のアクセス制御が想定通り行われていることをテスト等を用いて継続的に検証することが求められます。
なお、本脆弱性は特に仕様変更に伴うアクセス制御ポリシーの変化がもたらされた際に発生しやすいものであるため、そのようなタイミングは特に注意深くアクセス制御ロジックの実装やテストによる検証に取り組むようにしてください
No.8 Firestoreのセキュリティルールにおけるドキュメントのスキーマ検証不備
- 深刻度:低-中
- 発生頻度: 高
概要
以前の記事で紹介した通り、Firestoreはスキーマレスなドキュメント指向型のデータベースです。
スキーマレスであるという特徴は、仕様変更に伴うデータモデルの変化等に対して柔軟に対応できるという点においてアジリティの高いサービス開発を実現する上で有利に働きます。しかしながら、Firestoreはフロントエンドから直接アクセスされるという特徴を有するため、セキュリティルール上で想定外のスキーマを持つデータの格納が拒否されていないとバグや脆弱性に発展するおそれがあります。
たとえば、書籍の情報を扱うサービスを想定します。このサービスでは、書籍のデータモデルを下記のように定義しているとします。
type Book = { title: string; // 書籍の題名 isbn: string; // 書籍のISBN price: number; // 書籍の価格 published: boolean; // 書籍が出版済みであればtrue };
このデータに対応するドキュメントをFirestoreのbooks
コレクションで管理する際、作成されるドキュメントのスキーマを検証するには以下のようなセキュリティルールを実装する必要があります。
function checkBookSchema(book) { let expectedKeys = ['title', 'isbn', 'price', 'published']; return ( book.keys().hasAll(expectedKeys) && book.keys().hasOnly(expectedKeys) && book.title is string && book.isbn is string && book.price is int && book.published is bool ); } match /books/{docId} { allow create, update: if ( checkBookSchema(request.resource.data) && // 認証認可のチェック // ... ); }
ここで、上記のcheckBookSchema
関数で実装しているようなスキーマ検証用のロジックが欠落している場合、望ましくない結果がもたらされるおそれがあります。詳細については、次節で説明します。
リスク
スキーマ検証に不備がある場合、以下に示すリスクがもたらされるおそれがあります。
- 必要なキーが存在しないデータの作成によるバグの発生
- あるキーに紐づくデータの型の不一致によるバグの発生
- 想定外のキーが存在するデータの作成による潜在的な脆弱性の発生
まず、1つ目および2つ目のケースについて説明します。
たとえば、published
キーが存在しないドキュメントや、published
キーにbool
以外の型のデータ(文字列等)がbooks
コレクションに作成されたとします。この場合、フロントエンドアプリケーションで当該キーをもとに出版済みかどうかを示すUIを制御しているコンポーネントが意図しない挙動を示すおそれがあります。
次に、3つ目のケースについて説明します。
攻撃者によってbooks
コレクションに以下のような想定されないキー(deleted
)が含まれたドキュメントが追加されたとします。
{ "title": "test-title", "isbn": "test-isbn", "price": 1000, "published": true, "deleted": true }
このドキュメントが追加された後、仕様変更によってデータモデルに論理削除済みかどうかを示すdeleted
フラグが追加されたとします。また、当該フラグはサービスの管理者によってしか操作できないものとします。この場合、攻撃者は本来は権限を持たない論理削除フラグの追加を達成できたことになります。
上記の例は大きな影響をもたらす脆弱性とはなり得ませんが、このように仕様変更に伴ってあるデータモデルが持つキーが増えた場合、それと同一のキーを持つドキュメントが事前に作成されていると、権限昇格や情報漏洩などにつながる潜在的な脆弱性となるおそれがあります。
対策
Firestoreのセキュリティルールにおいて、作成(create
)および更新(update
)されようとしているドキュメントの内容が想定されたスキーマに一致するかどうか検証するロジックを実装してください。
以下に、スキーマ検証においてよく用いられるロジックの例を示します。
// 必要なキーがすべて存在することをチェック let requiredKeys = [...] request.resource.data.keys().hasAll(requiredKeys) // 想定外のキーが存在しないことのチェック let optionalKeys = [...] request.resource.data.keys().hasOnly(requiredKeys.concat(optionalKeys)) // 各キーのデータ型のチェック request.resource.data.title is string // 文字列型 request.resource.data.price is int // 整数型 request.resource.data.published is bool // ブール型 request.resource.data.createdAt is timestamp // タイムスタンプ型
No.9 Firestoreのセキュリティルールにおけるフィールド値の検証不備
- 深刻度: 低-高
- 発生頻度: 高
概要
No.6およびNo.8では、Firestoreのセキュリティルールにおいて認証状態や作成されようとしているドキュメントのスキーマを検証することの必要性について説明しました。ここまでの対策で「そもそもアクセス権限を持たないユーザによってFirestore上のデータを閲覧、操作できないこと」および「想定外のスキーマを持ったデータが作成されないこと」を担保できるようになりますが、これだけではまだ不十分です。
No.8ではドキュメントのキーを検証しましたが、本節ではドキュメントの値に着目します。
No.8と同じく、書籍の情報を扱うサービスを想定します。書籍のデータモデルは以下の通りでした(再掲)。
type Book = { title: string; // 書籍の題名 isbn: string; // 書籍のISBN price: number; // 書籍の価格 published: boolean; // 書籍が出版済みであればtrue };
また、このデータを格納するコレクションに対して、以下のセキュリティルールを実装していました。
function checkBookSchema(book) { let expectedKeys = ['title', 'isbn', 'price', 'published']; return ( book.keys().hasAll(expectedKeys) && book.keys().hasOnly(expectedKeys) && book.title is string && book.isbn is string && book.price is int && book.published is bool ); } match /books/{docId} { allow create, update: if ( checkBookSchema(request.resource.data) && // 認証認可のチェック // ... ); }
この場合、更新後のドキュメント内のフィールド値が検証されていないため、たとえばisbn
フィールドにISBNのフォーマットに沿わない文字列を格納したり、price
フィールドの値を自由に操作したりすることが可能です。
そのため、もし当該サービスが書籍の購買機能を持っており、ある書籍の価格をprice
フィールドの値として扱っていた場合、攻撃者によって購入前に下記のようなコードを実行されることで0円で書籍を購入されるおそれがあります。
import { initializeApp } from "firebase/app"; import { getFirestore, getDoc, setDoc, doc } from "firebase/firestore"; const app = initializeApp({ ... }); const db = getFirestore(app); (async () => { const docRef = doc(db, "books", "..."); const docSnap = await getDoc(docRef); const book = docSnap.data(); await setDoc(docRef, { ...book, price: 0, }); })();
加えて、別の例を示します。ユーザの情報を格納するコレクションに下記のようなドキュメントを格納することでロールベースのアクセス制御を実施しているサービスを想定します。
// usersコレクション { // 管理者ロールのユーザを表すドキュメント "123456789": { "name": "flatt1", "role": "admin" }, // 一般ロールのユーザを表すドキュメント "987654321": { "name": "flatt2", "role": "member" } }
そして、Firestoreのセキュリティルール上では、このロールを参照することでアクセス制御を実現しているとします。
function getRole(uid) { return get(/databases/$(database)/documents/users/$(uid)).data.role; } function isMember(uid) { return getRole(uid) == "member"; } function isAdmin(uid) { return getRole(uid) == "admin"; } match /secrets/{docId} { let uid = request.auth.uid; // 読み取りは全ロールのユーザが実行可能 allow read: if ( (isMember(uid) || isAdmin(uid)) && ... ); // 新規作成および更新は管理者ロールのユーザのみが実行可能 allow create, update: if ( isAdmin(uid) && ... ); }
ここで、users
コレクションのドキュメントの更新に対する制限が存在しないと、下記に示すコードによって権限昇格が可能になるおそれがあります。
import { initializeApp } from "firebase/app"; import { getAuth, onAuthStateChanged } from "firebase/auth"; import { getFirestore, setDoc, doc } from "firebase/firestore"; const app = initializeApp({ ... }); const auth = getAuth(app); const db = getFirestore(app); onAuthStateChanged(auth, (user) => { const docRef = doc(db, "users", user.uid); setDoc(docRef, { role: "admin", }); });
リスク
不正な値が設定されたドキュメントがFirestore上に作成されるおそれがあります。
本脆弱性による影響は、フォーマットに沿わないデータによって発生するフロントエンドアプリケーション上でのバグ等の軽微なものから、概要の節で示したような価格や権限情報等の重要度の高いフィールドの改ざんに伴う不正な処理の実行や権限昇格といった重大なものまで多岐にわたります。
対策
以下の項目について検証する条件式がセキュリティルール上に存在することを確認してください。なお、後者については「フィールド値の検証不備」というよりも認可不備に該当する脆弱性ですが、重要度が高いためここで紹介します。
- あるフィールドに格納されることが想定されない値を設定するリクエストが拒否されていること
- 価格や権限情報等の重要度の高いフィールドを操作可能なユーザが適切な権限を持ったユーザに制限されていること
あわせて、以下に汎用性の高いフィールド値検証用のロジックを示します。
- 作成元ユーザおよび更新元ユーザを表すフィールド
request.resource.data.[フィールド名] == request.auth.uid
- 作成日時および更新日時を表すフィールド
request.resource.data.[フィールド名] == request.time
- 指定可能な値が事前に決まっているフィールド
request.resource.data.[フィールド名] in [指定可能な値1, ...]
No.10 Cloud Storageのセキュリティルールにおけるアップロード制限の不備
- 深刻度: 中
- 発生頻度: 中
概要
Cloud Storageのセキュリティルールでは、Firestoreと同様に認証状態やアクセス権限を検証することに加えて、アップロードされようとしているオブジェクトのコンテンツタイプやサイズを適切に検証する必要があります。
たとえば、以下のセキュリティルールが実装されたCloud Storageのフォルダ(files
)があるとします。
match /files/{filename} { allow create: if ( // 認証状態のチェック request.auth != null && // 以下、request.auth の網羅的なチェック ... ); }
上記のセキュリティルールによってアクセス権限を持たないユーザによるオブジェクトのアップロードは制限されています。一方で、上記のルールでは、当該フォルダに対して任意のオブジェクトをアップロードすることが可能です。
リスク
たとえば、意図しないコンテンツタイプを持つオブジェクトが格納されたり、そのオブジェクトの配布に利用されたりするおそれがあります。
また、巨大なサイズを持つオブジェクトを大量にアップロードされると、そのサイズに応じた利用料金が請求されるため、サービス運用者に対するEDoS(Economic Denial of Sustainability)攻撃につながるおそれがあります。
補足事項として、Cloud Storageでは最大5TBのサイズを持つオブジェクトのアップロードが許可されています。たとえば、ある攻撃者が1Gbpsの回線上で継続的に巨大なサイズを持つオブジェクトを1ヶ月間アップロードし続けた場合、TCPのオーバヘッド等による影響などは考慮せず大雑把に計算すると、合計して約324TB分のオブジェクトをアップロードできることになります。ここで、利用しているCloud Storageのロケーションが東京(asia-northeast1)であり、かつストレージクラスがStandardだった場合、1GBごとの料金は$0.023かかります。そのため、324TBのデータを1ヶ月間保有すると、2023年6月現在のドル円レートで約100万円強かかる計算になります。
対策
Cloud Storageのセキュリティルールにおいてオブジェクトのコンテンツタイプおよびサイズを検証することが有効です。
たとえば、アップロードされるオブジェクトが画像に限定される場合、以下のようにしてコンテンツタイプを制限できます。
request.resource.contentType.matches('image/.*')
また、アップロードされるオブジェクトのサイズを10MB以下に限定したい場合は、以下のようにして制限できます。
request.resource.size <= 10 * 1024 * 1024
基本的にはこれらの検証を組み合わせておく必要があります。
おわりに
本稿では、弊社がこれまでに実施してきたFirebase診断の事例や筆者独自の調査をもとに、Firebaseを活用して開発されたサービスにおいて発生しやすい脆弱性の概要やそれにより引き起こされるリスク、およびその対策を深刻度や発生頻度の評価を踏まえつつお伝えしました。
本稿を通じて、Firebaseを活用したサービスにおいて発生しやすい脆弱性や対策について理解を深めていただけますと幸いです。
さて、本稿ではFirebase利用に伴って発生するおそれのある様々な脆弱性やリスクについて説明しましたが、実際のところ今回紹介したものはほんの一部であり、実際の診断事例では分類が難しい複雑な脆弱性や仕様を深く理解していなければ発見できない難易度の高い脆弱性等が見つかることが多々あります。株式会社Flatt Security(https://flatt.tech)では、専門のセキュリティエンジニアによる Firebase に対する脆弱性診断サービスを提供しています(もちろん、Firebase を活用しているアプリケーション全体の診断も可能です)。もし、Firebase を用いた開発におけるセキュリティ上の懸念事項が気になる場合や、実際に診断について相談したいという場合は、ぜひ下記バナーからお問い合わせください。
上記のデータが示すように、診断は幅広いご予算帯に応じて実施が可能です。ご興味のある方向けに下記バナーより料金に関する資料もダウンロード可能です。
また、Flatt Security はセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式 Twitter のフォローをぜひお願いします!
では、ここまでお読みいただきありがとうございました。