Cloud Firestoreのruleで`update`の条件を書く時に気をつけたいこと

久々に`firestore.rules`のことで記事を書きたいなと思い、
今回は`firestore.rules`で`update`の条件に焦点を当てて、書く時に気をつけたいことを書いてみます。

## 何に気をつけるべきか
もちろん、「どのユーザーが`update`を実行できるか」を制御するのも大事です。（制御しないと他者が勝手に更新をして悪意ある情報に書き換え可能ですよね。）
それともう一つ大事なのが、**「どのフィールドは書き換え不可なのか」**です。
「適切な権限を持つユーザーが`update`を実行できる状態」イコール、「更新によって適切なデータに更新される」には**ならない場合**があります。

次のようなドキュメントを例にして説明してみます。
`post`という、記事を表すドキュメントで、タイトル、内容、作成者、作成日、更新日の情報を持っています。
作成時(create)には全ての情報が書き込まれ、書き込みを行うユーザーの、認証情報であるuidと、`authorID`が一致しているときに`create`できるものとします。

```ts
// '/posts/xxxxxx' のドキュメント
const post = {
  title: 'Cloud Firestoreのruleで`update`の条件を書く時に気をつけたいこと',
  body: '今回は`firestore.rules`で`update`の条件に焦点を当てて、書く時に気をつけたいことを書いてみます。 ...' ,
  authorID: 'yyyyyy', // `/users/yyyyyy`のドキュメントのID、
  createTime: '2019-05-28T09:00:00.000Z',
  updateTime: '2019-05-28T15:00:00.000Z'
}
```

これに対応する`firestore.rules`を書いてみます。
`update`以外の操作や、フィールドの値のバリデーションは省略します。

```ts
service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{postID} {
      allow update: if incomingData().authorID == request.auth.uid;
    }
  }
  
  function existingData() {
  	return resource.data;
  }
  
  function incomingData() {
  	return request.resource.data;
  }
}
```

このように記述すると、「`authorID`と認証ユーザのUIDが一致していたら更新が可能」ということになり、本人のみが更新可能であるように見え、
ルールとしても正しいように見えます。しかしこれだけでは**不十分**です。

## 何が不十分だったのか

これだけでは不十分の理由としては、

- authorIDを書き換えることが可能になってしまう。
- 更新の結果、authorIDが、更新操作を行ったユーザーのIDと一致すればいいので、乗っ取りが可能になる
- `createTime`を書き換えることができてしまうので、新着順で掲載するようなロジックがある場合に、新着をずっと占拠することが可能になる


といった点があげられます。
特に問題なのは、一見本人のみが操作ができるように見えて、**実質他人が書き換え出来て乗っ取りが可能**である点です。これは非常にまずいです。
`incomingData().authorID`(==`request.resource.data.authorID`)は、`update`の操作を行った時に、最終的にこうなるという結果の値が入ってくるので、

```js
// '/posts/xxxxxx' のドキュメント(変更前)
const post = {
  title: 'Cloud Firestoreのruleで`update`の条件を書く時に気をつけたいこと',
  body: '今回は`firestore.rules`で`update`の条件に焦点を当てて、書く時に気をつけたいことを書いてみます。 ...' ,
  authorID: 'yyyyyy', // `/users/yyyyyy`のドキュメントのID
  createTime: '2019-05-28T09:00:00.000Z',
  updateTime: '2019-05-28T15:00:00.000Z'
}
```

```js
// '/posts/xxxxxx' のドキュメント(変更後)
const post = {
  title: 'Cloud Firestoreのruleで`update`の条件を書く時に気をつけたいこと',
  body: '今回は`firestore.rules`で`update`の条件に焦点を当てて、書く時に気をつけたいことを書いてみます。 ...' ,
  authorID: 'zzzzzz', // `/users/zzzzzz`のドキュメントのID
  createTime: '2019-05-28T09:00:00.000Z',
  updateTime: '2019-05-28T15:00:00.000Z'
}
```

となった場合に、`incomingData().authorID`は`zzzzzz`を指すことになります。これと、操作しているユーザーのUIDが`zzzzzz`だった場合に`update`の操作が許可されてしまいます。恐ろしい...


## ルールを修正する

この問題を修正するには、

- `authorID`が、更新前後で変化しないこと
- `createTime`が更新前後で変化しないこと

を条件に加えてあげます。


```ts
service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{postID} {
      allow update: if incomingData().authorID == request.auth.uid
                    && incomingData().authorID == existingData().authorID //追加
                    && incomingData().createTime == existingData().createTime; //追加
    }
  }
  
  function existingData() {
  	return resource.data;
  }
  
  function incomingData() {
  	return request.resource.data;
  }
}
```

こうすることで、別のauthorIDに書き換えられたり、乗っ取られたりすることを防ぐことができます。

### これじゃだめなの?

```ts
service cloud.firestore {
  match /databases/{database}/documents {
    match /posts/{postID} {
      allow update: if existingData().authorID == request.auth.uid;
    }
  }
  
  function existingData() {
  	return resource.data;
  }
  
  function incomingData() {
  	return request.resource.data;
  }
}
```

`existingData().authorID`とauthのUIDを比較する、つまり更新前のDBの状態での`authorID`と比べる方法でも良いのではと思うかもしれませんが、これだと△です。
先程のようにしっかりと防ぐことができず、

- author本人が意図しても意図しなくても、別の`authorID`に書き換えができてしまう

といった問題が起きてしまいます。

## 書き換えを防いだ方が良いフィールド

- 作成時に書き込んだら変わらない他ドキュメントへの参照(IDやDocumentReference)
- `createTime`といった作成日の情報
- CloudFunctions等で書き込み、クライアント側からは更新をしない情報

これらは `incomingData().xxx === existingData().xxx` といった書き方で、変更前後で書き換わっていないかどうかをしっかり見るようにすると良いでしょう。

## まとめ
今回はちょっとした例を踏まえて、うっかりやってしまいそうな事例とその対処を紹介してみました。
自分の書いたルール、大丈夫か一度見直してみるといいかもしれません。