Firebase App Checkでアプリを保護する　悪意のあるアプリからの攻撃もやってみた

## はじめに

Firebaseを利用してアプリを開発する場合、Firebae Authenticationでユーザー認証を行う事で、ユーザーを保護するという事を行うだろう。そしてCloud Firestoreなどではセキュリティルールにより、データを保護する事になる。

ただ、Firebaseではアプリの設定情報（firebaseConfig）は[公開される事を前提にしており](https://firebase.google.com/docs/projects/api-keys?hl=ja)、そのためFirebaeのアプリの設定情報をそのままそっくりコピーすれば別のアプリであってもFirebaseの各リソースへのアクセスを要求する事はできる。

そこで今回はApp Checkを導入する事でアプリ（クライアント）の真正性を証明し、アプリを保護するという事をやってみたいと思う。
また、App Check未導入時にはどういう事が起きうるか？も「おまけ」で実際に実装してみて検証してみた。

※ちなみに、以下のように環境変数から値を設定するように実装していても、ビルドされてWeb上で表示される際には、この情報はJavaScriptのどかに存在し、隠蔽することにはならないので注意が必要。

```js
const firebaseConfig = {
	apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
	authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
	projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
	storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
	messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
	appId: import.meta.env.VITE_FIREBASE_APP_ID,
	measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID
};
```

以下は上記のコードをビルドした後のファイルの一部（開発途中なアプリなためマスキングしている）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/6769d76f-4563-29ea-6ed7-8c2898543103.png)

## App Checkを導入する

導入にあたっては以下の手順で行う。

1. reCAPTCHAへの登録
1. FirebaseでApp Checkを設定する
1. アプリ側でApp Checkの実装をする
1. 実際にDeployしてみて、App Checkの状態を確認してみる

- 参考：[ウェブアプリで reCAPTCHA v3 を使用して App Check を使ってみる](https://firebase.google.com/docs/app-check/web/recaptcha-provider?hl=ja)

### reCAPTCHAへの登録
[reCAPTCHAのv3 Admin Console](https://www.google.com/recaptcha/admin)から新規にsiteKeyとsecretKeyを生成する。

AdminコンソールにアクセスするとデフォルトではEnterpriseになっているので、`Switch to create a classic key`からフリーのreCAPTCHA登録に切り替える。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/182e5d16-1cae-203a-8a26-f11f098628f5.png)

登録内容はFirebaseのHostingを利用するのであればそのドメインを登録すればいい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/9968c83c-a54e-1860-212a-94836142ef3e.png)

登録後に払いだされるsiteKeyとsecretKeyを控えておく（siteKeyもマスクしているが、これはフロントエンドに存在するキーで本来的には公開して問題ない）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/fe5f5240-e50c-e2d1-c0d4-e83f5157f091.png)

### FirebaseでApp Checkを設定する

続いて、FirebaseのコンソールからApp Checkの設定をしていく。以下の画面の`登録`から`reCAPTCHA`を選択して、上記の[reCAPTCHAへの登録](#recaptchaへの登録)で取得したsecretKeyを設定する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/811604e5-4728-bea1-047a-ad4686908c6e.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/187c79e1-82fc-3ed9-fba0-6ef583d56c48.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/0730b612-71f4-8400-6e47-5f3c504922ce.png)

これでFirebaseの設定は完了になる（実際には、ローカル環境から本番のFirebaseリソースにアクセスする際には、[ウェブアプリのデバッグ プロバイダで App Check を使用する](https://firebase.google.com/docs/app-check/web/debug-provider?hl=ja)に書かれている追加設定が必要になるが、それについては後程取り上げる）。

### アプリ側でApp Checkの実装をする

実装は特に難しい事はなく、以下のように初期化するだけでいい。

```js:
...
import {
	initializeAppCheck,
	ReCaptchaV3Provider,
	getToken
} from 'firebase/app-check';

const firebaseConfig = {
	...
};

const app = initializeApp(firebaseConfig);
...
const appCheck = initializeAppCheck(app, {
	provider: new ReCaptchaV3Provider(
		import.meta.env.VITE_FIREBASE_RECAPTCHA_SITEKEY
	),
	isTokenAutoRefreshEnabled: true
});
getToken(appCheck)
	.then(() => {
		console.log('AppCheck:Success');
	})
	.catch((error) => {
		console.log(error.message);
	});
...
```

- 参考：[3. App Check を初期化する](https://firebase.google.com/docs/app-check/web/recaptcha-provider?hl=ja#initialize)

### 実際にDeployしてみて、App Checkの状態を確認してみる

以下はFirestoreにリクエストをした際のApp Checkのモニタリング結果。青はApp Checkを通った正規のリクエストで、黄色はApp Checkトークンがないリクエストを示している（以下はモニタリングの結果を確認するために、敢えてApp Checkトークンなしのリクエストを何回かしたもの）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/41da80c7-2023-9bbf-b2fa-b11030815d34.png)

ここまでApp Checkの導入は終わり。以降でローカル環境でApp Checkありの開発を行うための方法を見ていく（[Firebase Local Emulator Suite](https://firebase.google.com/docs/emulator-suite?hl=ja)を利用しているのであればこの対応は不要だが、ローカルの開発環境から本番のFirebaseのリソースにアクセスするには必要になる）。

## ローカル環境からのリクエストでもApp Checkをクリアするようにする

まず、既存の実装に以下のようなコードを追記し、`window.self.FIREBASE_APPCHECK_DEBUG_TOKEN`をtrueにする。
```js
if (import.meta.env.DEV) window.self.FIREBASE_APPCHECK_DEBUG_TOKEN = true; // <- これを追加
const appCheck = initializeAppCheck(app, {
	provider: new ReCaptchaV3Provider(
		import.meta.env.VITE_FIREBASE_RECAPTCHA_SITEKEY
	),
	isTokenAutoRefreshEnabled: true
});
```

これを追記すると、chromeのconsole上にデバックトークンが表示される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/464082d4-766b-a8eb-c583-eee6c7f44067.png)

これをFirebaseコンソールのApp Checkから登録すればいい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/7c65a837-6dd6-8b35-a0a2-2bae2c000a76.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/ed3df022-7a1f-d06c-27e9-bcf26a162eaa.png)

これでローカルの開発環境からFirebaseへのリクエストが問題なく通るようになる。

- 参考：[ウェブアプリのデバッグ プロバイダで App Check を使用する](https://firebase.google.com/docs/app-check/web/debug-provider?hl=ja)

## まとめ

今回は、App Checkを利用してアプリを保護するという事をやってみた。App Checkにより認証やセキュリティールールによるユーザーの保護だけでなく、アプリ（クライアント）の保護もできるので、本番のリリース前には対応するのがいいだろう。

## おまけ
### firebaseConfigをコピーした別アプリからユーザー情報を取得する（アプリになりすまされた場合に起こる事）

簡易的に以下のようなコードで悪意のあるアプリを再現してみた。
```html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link
      href="https://cdn.jsdelivr.net/npm/vuetify@3.1.13/dist/vuetify.min.css"
      rel="stylesheet"
    />
  </head>
  <body>
    <div id="app">
      <v-app>
        <v-app-bar density="compact">
          <v-app-bar-title>ダミーアプリ</v-app-bar-title>
        </v-app-bar>
        <v-main class="bg-grey-lighten-5">
          <v-container>
            <v-row>
              <v-col cols="12">
                <v-btn @click="login">Googleでログイン</v-btn>
              </v-col>
              <v-col cols="12">
                <v-btn @click="getUser">user情報をFirestoreから取得</v-btn>
              </v-col>
            </v-row>
          </v-container>
        </v-main>
      </v-app>
    </div>
  </body>
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vuetify@3.1.13/dist/vuetify.min.js"></script>
  <script type="module">
    const { createApp } = Vue;
    const { createVuetify } = Vuetify;
    import { initializeApp } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app.js";
    import {
      getAuth,
      GoogleAuthProvider,
      signInWithPopup,
    } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-auth.js";
    import {
      getFirestore,
      doc,
      getDoc,
    } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-firestore.js";
    import {
      initializeAppCheck,
      ReCaptchaV3Provider,
    } from "https://www.gstatic.com/firebasejs/9.15.0/firebase-app-check.js";

    const firebaseConfig = {
      apiKey: "...",
      authDomain: "....firebaseapp.com",
      projectId: "...",
      storageBucket: "....appspot.com",
      messagingSenderId: "...",
      appId: "...",
      measurementId: "...",
    };

    const app = initializeApp(firebaseConfig);
    const auth = getAuth(app);
    const provider = new GoogleAuthProvider();
    const db = getFirestore(app);
    // 以下は「無効なリクエスト」を再現するためのコード
    // const appCheck = initializeAppCheck(app, {
    //    provider: new ReCaptchaV3Provider("dumydumydumydumydumydumydumy"),
    //    isTokenAutoRefreshEnabled: true,
    //  });

    createApp({
      data() {
        return {
          message: "Hello Vue!",
          uid: null,
        };
      },
      methods: {
        login() {
          signInWithPopup(auth, provider)
            .then((result) => {
              const credential =
                GoogleAuthProvider.credentialFromResult(result);
              const token = credential.accessToken;
              const user = result.user;

              this.uid = user.uid;
              console.log(this.uid);
            })
            .catch((error) => {
              ...
            });
        },
        async getUser() {
          const userDocSnap = await getDoc(doc(db, "users", this.uid));
          console.log(userDocSnap.data());
        },
      },
    })
      .use(createVuetify())
      .mount("#app");
  </script>
</html>
```

上記のコードをVS CodeのLive Serverで立ち上げてログイン後にFirestoreからデータを取得できるか？検証してみると、簡単にデータを取得できてしまう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/90dd9961-a4bb-c17d-d165-d88eaa3b5443.png)

上記のリクエストをApp Checkのモニタリングで確認してみる。
App Checkのトークンがない古いSDKからのリクエストとしか表示されないのでApp Checkを有効にしていない場合、簡単にデータが取られてしまうし、悪意のある書き込みを正規の認証後に行える状態になってしまう（`無効なリクエスト`はダミーのApp Checkトークンを設定してみた場合のモニタリング結果）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/dc370e73-80c8-1fd0-5f86-33d0f4d77e8f.png)

上記のように他のアプリ（クライアント）からのリクエストを遮断するために、App Checkを適用する必要がある。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/c14ba152-1971-8a07-487a-f5c9335d57de.png)

App Checkを適用した後、同じように悪意のあるアプリからリクエストを行ってみると、今度は以下のようにエラーになり、リクエストが遮断される事が確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/f7f6d5de-0386-3be7-2a2f-7f6abdf5d8ee.png)

モニタリング上は、先ほどと変わらず`クライアント リクエストが古い`として表示される。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1372684/c59129e4-4d60-be42-5ae1-068a4ccf1316.png)

※別の話として、本番リリース後にはFirebase Authenticationの`承認済みドメイン`から`localhost`を削除できなら削除すべきだろう。これにより、ローカルの環境からの認証ができなくなるので、今回作成したような悪意のあるアプリで認証できてしまうという事を防げる。