Firestore + typescript を使ってフロントエンドアプリ(Chrome拡張)を作っているのですが、DOS攻撃をうけたり、単純に利用者数が多かったりすると普通にFirestoreの無料枠を超える可能性があります。コストリスクを下げるために、色々対策を実施したので紹介しようと思います。

Firestoreの料金

まずは、FireStoreの料金体系について紹介します。思ったより無料枠が少ないです。

無料枠

こちらを参照しました。単位が書いてないのですが、おそらくアクセスするドキュメント数です。読み取り、書き込み、削除の制限が結構厳しくて、何も考えずに実装していると超えてしまいそうです。そうじゃなくても、一覧画面とかで F5 アタックとかされたら簡単に無料枠を超えてしまいそうです。

処理区分 無料枠
保存データ 1 GiB
ドキュメントの読み取り 50,000/日
ドキュメントの書き込み 20,000/日
ドキュメントの削除 20,000/日

無料枠を超えた場合の料金

こちらを参照しました。こちらには単位が書いてあります。そこまですごい金額ではないですが、ちょっと怖いですね。

処理区分 無料枠超過分の料金
ドキュメントの読み取り ドキュメント 100,000 点あたり $0.038 = 約4.18円
ドキュメントの書き込み ドキュメント 100,000 点あたり $0.115 = 約12.65円
ドキュメントの削除 ドキュメント 100,000 点あたり $0.013 = 約1.42円
保存データ $0.115/GiB/月 = 約12.65円/GiB/月

ここからは、上記を踏まえた上で、コストリスクと抑える対策を紹介していきたいと思います。

①予算枠を超えたらアラートメール

Cloud Consoleのお支払いのページから対象の請求先アカウント名(Firebaseのお支払い)をクリックして、「予算とアラート」セクションに移動します。

あとは、「+予算を作成」ボタンから予算を作成します。
以下のように、複数段階に分けてアラート閾値ルールを設定することができます。

こちらにあるように、閾値をこえたら請求先アカウント管理者、請求先アカウントユーザにアラートメールが送信されるようです。

アラートメールが飛んできたら、すぐにFirebaseのプロジェクトを停止したくなると思いますので、手作業で停止する方法を書いておきます。

手動停止1:プロジェクトの削除 & 復元

Firebaseのコンソール画面ではプロジェクトの停止という機能はなく、プロジェクトを削除して30日以内に復元するということができるようです。

プロジェクトの削除は、Firebaseのコンソール画面から対象のプロジェクトを選択し、ギアマークからプロジェクトの設定に移動し、下の方にスクロールすると「プロジェクトの削除」というボタンから行います。

クリックすると、以下のダイアログが表示されるので全てチェックして削除します。

プロジェクトの復元は、削除時に受け取る以下のメールから行うのが良いと思います。

この resource pending deletion リンクから、削除保留中のリソース一覧を表示することができますので、対象のリソースをチェックして「復元」を行います。

すると以下のダイアログが表示されるので、復元をクリックして、復元を行います。このダイアログをみる限り請求が有効にならないようです。

実際に実行してみて、Firebaseのコンソール画面を表示すると、何事もなかったようにプロジェクトが表示されました。今回使っているAuthenticationFirestoreFunctionsを確認しましたが、データはそのままで設定も特に消えてませんでした。

しかし、ダメになっているものもありました。

  • FirebaseのプランがSparkプラン(無料プラン)に戻ってしまう
    • こうなると請求の設定が必要なFunctionsは使えなくなっていました(実際使うと500エラー。もしかして無料プランで使える抜け道発見かと思いましたが、そんなことはありませんでした)
    • プランをSparkプランからBlazeプランに再度変更すると解決しました。ちなみに予算とアラートの設定は消えてませんでした
  • Cloud Functions の関数がなぜか壊れてしまうことがあった
    • 再現性は微妙ですが、一度Functionsの関数が壊れてしまいました
    • エラーログもUnhandled error function error(...args) { write(entryFromArgs('ERROR', args)); }とだけ出て、原因不明だったのですが、関数を削除して再度デプロイしたら直りました
  • Pub/Subのトピックが削除される
    • また同じ名前で作り直してあげればOKでした

削除=>復元は一見大丈夫そうなのですが、それなりにダメになるものがあるので、実運用で使うには少し不安あり、という感じです。

手動停止2:Firestoreが依存するApp Engineを無効化

Firestoreを停止したいだけなら、Firestoreが依存するApp Engineを無効化する手もあるようです。
App Engineの設定画面アプリケーションを無効にするボタンから簡単に無効化できます。

実際に無効化みたところ、Firestoreはちゃんと停止してくれましたが、AuthenticationsやFunctionsなどは動いているようでした。

有効化は、同じ画面のアプリケーションを有効にするボタンからできました。完全に立ち上がるまで数分かかりましたが、ちゃんとデータも残っていて問題なく動いてくれました。

アプリケーションを無効にすると、すべてのリクエストの処理が停止しますが、データやステータスは失われません。該当する場合は、引き続き請求が発生します。アプリケーションはいつでも再度有効にできます。

とのことですし、Firestore以外も完全停止したければFirebaseのプロジェクト削除=>復元がいいかもしれないけど、Firestoreを停止したいだけならこちらの方が良さそうですね。

②予算枠を超えたらFirestoreを自動無効化

こちらをみると、自分でプログラムを書いて色々設定すれば、予算が閾値を超えたらFirestoreが依存するApp Engineを自動的に停止することもできるようです。

仕組みとしては、予算とアラートPub/SubCloud Functionsの三つを利用します。作業は以下のような感じです。

  1. Pub/Sub トピックを予算に接続する
    • この中で、Pub/Subのトピックを作成する
    • 予算とアラートで閾値をこえたら、対象トピックのメッセージがパブリッシュされるようにする
  2. 対象トピックをサイブスクライブするCloud Functionsの関数を作って、その関数の中でApp Engineを無効化する

実際にやってみたので、手順を解説します。

Pub/Sub トピックを予算に接続

まずは、先ほど設定したCloud Consoleのお支払いの「予算とアラート」の設定を開き、下の方のPub/Sub トピックをこの予算に接続するをチェックします。

つぎにCloud Pub/Sub トピックを選択してくださいをクリックすると、There are no available topicsと言われるので、トピックを作成するをクリックします。

トピック作成画面が表示されるので、トピックIDを入力してトピックを作成をクリックします。

Cloud Functions で、Pub/Sub トピックによりトリガーされる関数を作成

素のCloud Functionsを使う方法は、こちらを参照してください。
今回は、元々Firebaseを使っているので、Cloud Functions for Firebaseを使って実現しようと思います。

Cloud Functions for Firebaseの準備

元々Firestoreを使っている時点でFirebaseのプロジェクトは作成済みだと思いますが、プロジェクトの作成はこちらから行うことができます。

次に、Firebaseのクライアントツールをインストールします。

npm install -g firebase-tools
firebase login # ブラウザが起動するので、対象googleアカウントを選択し、パーミッションを与える

さらに、プロジェクトのCloud Functions関係のソース一式を初期化します。
以下のコマンドで、firebase.jsonfunctionsフォルダが作成されます。

cd {path_to_project_dir}
firebase init functions

firebase.jsonを実際のフォルダパス(defaultだとfunctions)に修正します。

{
  "functions": {
    "predeploy": "npm --prefix functions run build"
  }
}

必要なライブラリを開発用としてinstallします。

npm install firebase-functions --save
npm install firebase-admin --save
npm install googleapis --save # app engineの停止に利用

ここまでで関数を書く準備ができました。

関数を実装

Pub/Sub 関数をトリガーする方法はこちらを、実際の関数の中身はこちらを参考にしました。

firebase/index.tsに以下のように実装します。サンプルがpythonだったので、typescriptで書き直しております。

import * as functions from 'firebase-functions'
import { google } from 'googleapis'

export const disableAppEngine = functions
  .region('asia-northeast1')
  .pubsub.topic('xxxxxxxxxxx') // ここは作成したトピック名を指定。"/"は含まない
  .onPublish(async (message) => {
    const data = JSON.parse(Buffer.from(message.data, 'base64').toString())
    const costAmount = data.costAmount
    const budgetAmount = data.budgetAmount

    console.log(`costAmount: ${costAmount}, budgetAmount: ${budgetAmount}`)
    if (costAmount <= budgetAmount) {
      console.log(`No action necessary. (Current cost: ${costAmount})`)
      return
    }

    const auth = new google.auth.GoogleAuth({
      scopes: ['https://www.googleapis.com/auth/cloud-platform'],
    })
    const authClient = await auth.getClient()
    google.options({ auth: authClient })

    const appengine = google.appengine('v1')
    const res = await appengine.apps.patch({
      appsId: 'xxxxxxxxxx', // appsIdにはFirebaseのプロジェクトID(=GCPのプロジェクトID)を指定
      updateMask: 'serving_status',
      requestBody: { servingStatus: 'USER_DISABLED' },
    })
    console.log(`disable app engine result: ${JSON.stringify(res.data)}`)
  })

実装が終わったら、関数をデプロイします。

firebase deploy --only functions

動作確認

動作確認するためには、実際にメッセージのパブリッシュを行う必要があります。

メッセージのパブリッシュはいろんな言語をサポートしてくれてるのですが、動作確認目的だと実質RESTgcloudの二択です。が、RESTを選択した場合でも結局gcloudコマンドでアクセストークンを確認する必要があるので、結局サービスアカウントを作成して、gcloudコマンドから実行することにしました。

こちらからサービスアカウントを追加して、Pub/Sub管理者ロールを設定して、鍵を作成し、鍵をダウンロードしたら、環境変数GOOGLE_APPLICATION_CREDENTIALSを設定します。

export GOOGLE_APPLICATION_CREDENTIALS={path_to_key}

もしくは、gcloud authで別の鍵を指定してたりプロジェクトを指定している場合は、環境変数を設定しただけではうまく動かないかもしれません。その場合は、以下のように設定を上書きします。

gcloud auth activate-service-account --key-file {path_to_key} --project {project_name}

また、gcloudコマンド自体まだインストールしてない場合は、こちらを参考にやってみてください。

準備が整ったら、gcloudコマンドでメッセージのパブリッシュを行います。メッセージの内容はこちらを参考にしました。budgetDisplayNameだけ実際のものに書き換えてください。

gcloud pubsub topics publish memorun_disable_app_engine --message '{
    "budgetDisplayName": "xxxxxxxxxx",
    "alertThresholdExceeded": 1.0,
    "costAmount": 100.01,
    "costIntervalStart": "2019-01-01T00:00:00Z",
    "budgetAmount": 100.00,
    "budgetAmountType": "SPECIFIED_AMOUNT",
    "currencyCode": "USD"
}'
messageIds:
- '14534284574868'

これでメッセージがパブリッシュされ、サブスクライバーであるCloud Functionsの関数がトリガーされて、FirebaseコンソールからFunctionsのログが出力されていることを確認できました。

disable app engine result: {"name":"apps/xxxxx/operations/xxxxx","metadata":{"@type":"type.googleapis.com/google.appengine.v1.OperationMetadataV1","method":"google.appengine.v1.Applications.UpdateApplication","insertTime":"2020-08-02T16:14:50.797Z","user":"xxxxx@appspot.gserviceaccount.com","target":"apps/xxxxx"}}

また、App Engineの設定を確認すると、アプリケーションが無効になっていました!

だいぶ大変でしたが、これでいきなりものすごい金額を請求されるリスクはほとんどなくなったと思います。

③【現在はできません】Firestore の1日の使用量の制限を設定

こちらにあるように、1日あたりの使用量の制限をできる風に書いてありますが、設定画面に行ってもそんな設定はどこにもありません。
原文の方を見ると

Important: Cloud Firestore no longer supports new App Engine spending limits. After December 12, 2019, you cannot apply new App Engine spending limits. Spending limits set before December 12 still apply.

と書いてあって、どうやらこの設定は2019年12月まであったようですが、それ以降はできなくなってしまったようです。
元々あった機能をわざわざ削除しているあたりに、なんとなくGoogleさんの悪意を感じてしまいますね...

④reCAPTCHA を導入してBOTアクセスを防ぐ

reCAPTCHAはスパムやBOTなどからサイトを保護するためのもので、v2とv3があります。これを使ってBOTアクセスを防ぐことにしました。

  • v2は、WEBサイトでみかけるやつで「この中から車が写っている画像を選んでください」みたいな感じで、ユーザ操作の結果でBOTアクセスなのかどうかを判断します。
  • v3は、ユーザフリクション(本来の目的を妨げるような事象、ユーザ操作)は一切なく、プログラムがスコア(1が正常で、0.0に近づくほどBOTの可能性が高い)を計算してくれます。

今回は、まずv3を使ってスコアを計算しBOTの可能性が高いと判断したら、v2を使って画像選択(チャレンジ)を行って最終的にBOTかどうかを判断することにしました。BOTと判断した場合は、24時間サービスを利用できないようにしています。

実装方法は、長大になったので別記事を起こしましたので、興味がある方はどうぞ。
typescriptプロジェクトにreCAPTCHA v2 & v3を導入

⑤データをキャッシュしてFirestore へのアクセス回数を減らす

今回作っているChrome拡張ではアイコン上にバッジを表示できるのですが、このバッジの表示有無や表示内容を取得するためにデータを取得する必要があります。当初は何も考えずタブが切り替わるたびにFirestoreにアクセスしてデータを取得していたのですが、普通に使っているだけで1日で1000回以上アクセスしてしまっており、ユーザが増えると簡単に無料枠を超えそうです。
というわけで、アイコン上のバッジ表示に必要な情報を、裏でずっと動いているbackground.jsでキャッシュしておき(変数に保持しておき)、Firestoreにアクセスする代わりにCacheを参照するようにしました。

処理内容は以下の通りです。

  • ログイン直後やログイン状態が変わった場合に、Firestoreからデータを取得してbackground.jsにキャッシュする(変数に保持する)
  • background.jsで、タブの作成・切り替えをchrome.tabs.onCreated.addListenerなどを使って監視し、キャッシュしたデータを元にバッジを表示する
  • Chrome拡張のポップアップページ内でデータが変更された場合は、popup.jsからchrome.runtime.sendMessageを使ってメッセージを送り、background.jsではchrome.runtime.onMessage.addListenerを使ってメッセージを受けとって、キャッシュデータを更新する

⑥割り当て(Quota)を設ける

あるユーザがアクセスできる回数に、短期間(1時間)と長期間(24時間)で割り当てを設けて、割り当てを超えた場合は一定期間アプリを利用できなくすることにしました。以下のようなイメージです。

処理区分 割り当て
ドキュメントの読み取り ドキュメント 100点 /1時間
ドキュメント 500点 /24時間
ドキュメントの書き込み ドキュメント 20点 /1時間
ドキュメント 100点 /24時間
ドキュメントの削除 ドキュメント 20点 /1時間
ドキュメント 100点 /24時間

アクセス回数の保存場所

アクセス回数をどこかに保存する必要があるのですが、保存する場所が悩ましく、以下のようにいくつか候補があると思います。

  • Firestore => 不採用
    • ○: ユーザがデータを操作できないという意味ではいい
    • ×: Firestoreへのアクセス回数を減らすための対策で、Firestoreアクセスが発生するという本末転倒なことが発生する
  • background.jsの変数 => 不採用
    • ×: Chrome拡張を再起動すると削除される。アプリ側はそれが不正操作か判断できない
  • IndexedDB => 採用
    • △: DevTools からクリアしたり削除したりすることができる。しかし、レコードが無い状態はアプリにとって不正な状態と判断できるので、多少の対応はできる(DB自体が無い状態は初回起動と区別できないがw)

正直どれもイマイチで、対策自体にコストがかかるFirestoreはやめておいて、多少なりともデメリットが小さいIndexedDBを利用することにしました。
IndexedDBを利用する場合、直接使うよりもDexie.jsを使う方が便利なので、こちらを使うことにしました。

使い方は別記事を起こしたので興味がある方はどうぞ。
IndexedDBをつかうならDexie.jsが便利

処理内容

処理内容は以下の通りです。

  • repositoryクラスでFirestoreにアクセスするたびに、indexedDB上のアクセス回数をカウントアップする
  • アプリ内でsetIntervalを使って数秒おきにアクセス回数が割り当てを超えてないかをチェックし、超えていた場合は画面上に「割り当てを超えています。24時間お待ちください」というようなメッセージを出して何もできなくする
  • background.jsで、24時間に1回アクセス回数をリセットする

⑦FirebaseのCDN利用を取りやめる

これはDevToolアタックを少しでもやりにくくするという程度のものです。。

CDNを利用してFirebaseのスクリプトを読み込んでいると、DevToolsのConsoleから簡単にFirestoreにアクセスできてしまいます。CDNから利用する場合、firebaseという名前のグローバル変数にfirebaseオブジェクトが格納され、そのfirebaseオブジェクトそのものに対して初期化処理を行うため、このグローバル変数経由でFirestoreに結構簡単にアクセスできてしまいます(悪用されるのも怖いので、サンプルコードを載せるのはやめておきます...)。

実際には、セキュリティルールをきちんと設定していれば、そのアカウントがアクセスできないデータにアクセスしようとすると、以下のようにエラーが発生しますので、データを悪用される恐れはありません。

error.ts:166 Uncaught (in promise) FirebaseError: Missing or insufficient permissions.

なので、ただの嫌がらせにしかならないのでわざわざやる人もいないと思いますが、万一嫌がらせを受けた場合、こちらにあるように、セキュリティルールの中でget()exists()などを使って条件を記載している場合、リクエストが拒否された場合でも読み取りオペレーションが発生してコストがかかってしまいます。これがいやだなぁと思う人は、スクリプトをCDNから読み込むのをやめて、バンドルファイルに含めるのもありだと思います。

DevTools Attack的なものを気にしだすとバンドルファイルに含めたところで全然完璧ではないので、たいして意味は無いかもしれません。自分は、Firestoreを含めるとバンドルサイズが大きくなりすぎるので、結局この対応はやめました。

さいごに

勉強も兼ねて今回色々対策をしてみたのですが、結局、

  • データをキャッシュしてFirestoreアクセスを減らす
  • 予算が閾値を超えたらFirestoreが依存するApp Engineを自動的に無効化する

の二つをやっておけばほぼ大丈夫なんじゃないかなと思いました。