近年、クラウドサービスを利用したシステムを目にする機会が増えてきました。セキュリティ診断サービスを提供する中でもクラウドサービスを利用したシステムのセキュリティ診断依頼は増加しています。その中でもGoogleが提供するFirebaseの各種サービスに対してセキュリティ診断を行う中で、様々な脆弱性を見つけることができました。
本ブログはFirebaseブログシリーズ第2弾となります。第1弾のブログではFiresbaseの概要やFirebase Authenticationに関するセキュリティ上の問題を紹介しました。今回紹介するのは、FirebaseにおけるデータベースサービスであるCloud Firestoreになります。Cloud Firestoreはデータベースサービスであるため様々なデータが保存されることになりますが、ここにセキュリティ上の問題が存在すると、第三者による意図しないデータの閲覧、作成、削除など、保存している情報によっては大きな被害につながる危険性があります。そこで本ブログではFirestoreのイメージを掴んでいただくために、Firestoreを使ったウェブアプリと一般的なウェブアプリを比較したうえで、意図せず作り込んでしまう脆弱性について実例を交えつつご説明をしようと思います。
できる限り具体例も交えた分かりやすい説明を心がけているので、Firebaseを既に利用している方だけではなく、これから利用しようとされている方々にとっても、本ブログがFirebaseを利用する際のセキュリティを考えるきっかけになれば幸いです。
ここでは、いくつかの軸でこのセキュリティルールの問題点と対策を例示していきたいと思います。
セキュリティルール単体で見るだけでも主に以下のような軸がありますが、今回は文字数の都合上★印を付けた観点について解説しようと思います。(特にフィールドレベルのアクセス制御における問題点は、対策となるセキュリティルールの実装が複雑なため、本投稿には収め切れませんでした。興味のある方は次回の更新をお待ちいただくか公式ページをご確認ください。)
ここでは簡単に以下のようなユーザ情報を管理するコレクション「users」があったとします。
※ドキュメントIDはFirebase Authenticationで認証されたユーザのUIDとします。
[
    {
        "document_id": "CRuyKX4sAMXuyaLHENZ0v0rP56n2",
        "data": {
            "address": "架空県A市",
            "avatar": "https://picsum.photos/id/1/256/256"
        }
    },
    {
        "document_id": "iK9LZwSyf4dgTLtUW9knKn6DDOA2",
        "data": {
            "address": "架空県B市",
            "avatar": "https://picsum.photos/id/2/256/256"
        }
    },
    ...
]
そして、セキュリティルールがこのようになっていたとします。
rules_version = '2';
service cloud.firestore {
  function isAuthenticated() {
    return request.auth != null
  }
  
  match /databases/{database}/documents {
    match /users/{uid} {
      allow read, write: if isAuthenticated();
    }
  }
}さて、かなりシンプルな例題としましたが、何が問題かどうかイメージつきますでしょうか?
まずは、上記のセキュリティルールで何を制御しているか整理してみましょう!
まずは各データ(フィールド)について整理しましょう。
avatarはユーザのアバター画像情報で、他ユーザからアクセスされるものと考えられます。addressはユーザの住所情報で、他ユーザからアクセスされることを意図しないものと考えられます。この2つのデータ(フィールド)を比較すると、addressについては他ユーザから見えないようにする必要がありそうです。
次に、セキュリティルールを確認していきます。
まず下記の部分は何をしているでしょう?
    function isAuthenticated() {
      return request.auth != null;
    }
これは、汎用的に使えるように認証判定を関数化しています。request.authがnullではないこと=認証されていることを検証しています。
認証されていればtrueになるし、認証されていなければfalseになりますね。
requestはユーザからのアクセスリクエストのコンテキストです。request.authは、認証情報が保管されているプロパティです。もしFirebase Authenticationのいずれの認証プロバイダからも認証されていない状態のリクエストであれば、このプロパティはnullになります。そして、その関数が使われているのが下記の部分です。
    match /users/{uid} {
      allow read, write: if isAuthenticated();
    }
ここではusersコレクションの任意のドキュメント({uid}に当たる部分)に対するリクエストにおけるセキュリティルールを定義しています。
まずallow式と呼ばれる部分に続くreadとwriteが、許可されるオペレーションです。このreadとwriteはそれぞれ「get,list」と「create,update,delete」に分解できます。なので、このallow式は以下のようなallow式と同じ意味になります。
allow get, list, create, update, delete: if isAuthenticated();
許可されるオペレーションの後、セミコロン:に続いてif文が記載されています。コンディションと呼ばれる部分で、ここがtrueであれば前述のオペレーションが許可されることになります。
つまり、このセキュリティルールを日本語で表現すると以下のようになります。
「users」コレクションの任意のドキュメントに対するリクエストがあった場合、
もしリクエストしたユーザが認証されていれば、
全ての読み書き操作を許可する。
これで現状の整理ができました。
次に問題点の解説と対策例の整理に進んでみましょう。
この投稿では、前述の通り下記の観点のみに焦点を当てて解説します。
※つまり、本投稿にある対策例だけではセキュリティ対策として十分ではないということですので、ご注意ください。(語り切れていない部分の対策も別途必要になります。)
まず認証の検証をしているのは下記の部分です。
    function isAuthenticated() {
      return request.auth != null;
    }
認証されているかをチェックしているので、これはこれで問題ないようにも思えます。
一旦、ここでFirebase Authenticationの仕様を理解する必要があります。
Firebase AuthenticationはFirebaseにおけるIDaaSに該当するプロダクトです。
Firebase Authenticationでは、様々な認証方法を選択/有効化できます。
この場では一旦、よく利用されているメール/パスワード認証プロバイダにフォーカスして語っておこうと思います。
Firebase Authenticationでは、ユーザ管理に関わる各種APIが提供されます。
メール/パスワード認証プロバイダを有効にすると、アカウントの新規登録やパスワード変更、アカウント削除などもこのAPIで実施できます。
そして、Firebase Authenticationのユーザのプロパティ情報にはemailVerifiedという情報を持っており、登録した段階ではfalseになっています。emailVerifiedは、対象アカウントのEmailアドレスの所有者確認が既に実施されているかどうかの識別に使用されます。
具体的には、sendEmailVerificationというAPIを呼び出して実際に検証メールをFirebase Authenticationから発信し、メールに記載されたリンクにアクセスすることでemailVerifiedの値がtrueに変化します。
つまり、Firebase Authenticationのメール/パスワード認証プロバイダにおいては、新規登録時にはメールの所有検証は含まれていないということです。emailVerifiedがfalseだったとしても、ログイン自体は可能です。なので、新規登録時に、実際には存在しないメールアドレスまたは他人のメールアドレスを設定したとしても、ログインすることができてしまいます。
アプリケーションのセキュリティ要求レベルにもよるところとは思いますが、基本的には他人になりすます等の悪用を防ぐために、認証検証時にはメール検証済みかどうかも併せて検証してしまうのが理想的でしょう。
そして、アプリケーションとしては、アカウント登録時にすぐに自動的に検証メールを発信させ(アカウント登録成功直後にsendEmailVerificationを呼び出す)、メール検証が済んでいないとアプリケーションが利用できない旨をUI上に表示するなどの対応がよさそうです。
話をセキュリティルールに戻します。
要はセキュリティルール上でemailVerifiedがtrueかどうかを追加で判別すればいいわけです。
この情報は、リクエストコンテキストのrequest.auth.token.emailVerifiedプロパティから確認可能です。
ここでは、認証されている検証にメール検証済みかどうかの条件も追加してしまいましょう。
  function isAuthenticated() {
    return request.auth != null &&
    request.auth.token.email_verified == true;
  }emailVerifiedの情報は持っています。Google認証プロバイダで登録すると(というよりGoogle認証プロバイダでログインすると勝手にアカウント登録されるのですが)、emailVerifiedはtrueの状態で登録されます。ただし、OAuth認証プロバイダで認証して登録されたユーザでも、updateEmailAPIを使って登録メールアドレスの更新は可能だったりします。メールアドレスを更新した場合はもちろんemailVerifiedはfalseに自動的に変わります。アクセス制御の問題点は参照系と更新系に分類しています。ここでは参照系のアクセス制御について語ってみたいと思います。
まず現状のセキュリティルールはこちらでした。
    match /users/{document=**} {
      allow read, write: if isAuthenticated();
    }お察しの方もいるかと思いますが、認証されていればreadもwriteもできるので、他ユーザのドキュメントも閲覧/編集可能です。
それは勿論よろしくないのですが、他ユーザからもアバター画像URLなどは閲覧できる必要があります。
こういった場合、サブコレクションを使用して他ユーザから閲覧できるべき情報とそうでない情報についてドキュメントを分離するアプローチが取れます。
ということでドキュメント構成を変更します。わかりやすく、ここでは非公開情報用のprivateサブコレクションを作成し、内部のドキュメントIDはinfoとします。
そして、addressフィールドについてはこのprivate/infoドキュメントに移動しましょう。
また、オペレーションについてもread,writeではなく、細かいオペレーションで記載します。
結果となるデータ構成イメージはこちらです(ここではprivateについてはmap型フィールドではなくサブコレクションとして見てください...)
[
    {
        "document_id": "CRuyKX4sAMXuyaLHENZ0v0rP56n2",
        "data": {
            "avatar": "https://picsum.photos/id/1/256/256",
            "private": {
                "document_id": "info",
                "data": {
                    "address": "架空県A市"
                }
            }
        }
    },
    {
        "document_id": "iK9LZwSyf4dgTLtUW9knKn6DDOA2",
        "data": {
            "avatar": "https://picsum.photos/id/2/256/256",
            "private": {
                "document_id": "info",
                "data": {
                    "address": "架空県B市"
                }
            }
        }
    }
]そして、セキュリティルールを下記のように更新します。
rules_version = '2';
service cloud.firestore {
  function isAuthenticated() {
    return request.auth != null &&
      request.auth.token.email_verified == true;
  }
  
  function isOwner(uid) {
    return request.auth != null &&
      request.auth.token.email_verified == true &&
      request.auth.uid == uid;
  }
  
  match /databases/{database}/documents {    
    match /users/{uid} {
      allow get: if isAuthenticated();
      match	/private/info {
        allow get, create, update: if isOwner(uid);
      }
    }
  }
}ここまで見ていただくだけでも、Firebaseアプリを開発したことのある人からすると、フロントエンドの修正が発生することも想像できるかと思います(恐ろしいですね)。
ですので、Firestoreをセキュアに利用したい場合は、データモデル設計段階でセキュリティについて評価しておくと、後から構成変更が発生する手戻り(フロントエンドのコードの修正)を抑えることができます。
ここまで、Firestoreにおける「認証の検証における問題点」「参照系のアクセス制御における問題点」についてまとめてきました。しかし、まだまだ「更新系のアクセス制御における問題点」等々の他の観点も含めてセキュリティルールを実装していく必要があります。残念ながら本投稿で語り切れなかった観点についても、また語る機会があれば是非投稿していけたらと思います。
Firestoreのセキュリティルールは、はじめは中々に気の遠くなるものにも思えますが、一度要点を掴んでしまえれば使いこなし易いですし、何よりセキュリティルールを念頭に置いてデータモデル設計ができますので、データ構成変更による開発の手戻りを抑えることができます。そしてその結果、スピーディーかつセキュリティも考慮されたアプリケーション実装/リリースが期待できます。(逆に、スピーディーに実装/リリースできてユーザが着々と増えたとしても、後からセキュリティインシデントにつながってサービス停止などの事態となってしまうのは開発側としてもユーザとしても悲しい結果となってしまいますからね。)
必要なセキュリティルールの実装を知れば、高品質でスピード感のあるプロダクトが提供できる、と語りましたが、どうしてもセキュリティルールの理解は大変な面もあります。
UBSecureでは、「Firebase診断サービス」を提供しています。また、設計段階で支援する「Firebaseセキュリティ設計レビュー支援」も受け付けております。第三者からのチェックを受けておきたい場合や、社内でセキュリティ観点の知見が足りないとお考えのケースなど、是非お気軽にご相談いただければと思います。