【Vue.js】Vue.jsのSSRで簡単に情報漏洩してしまう方法（cross-request state pollution）

## はじめに

Nuxt3が正式にリリースされてから約2年経ちましたが、そろそろ業務で扱うのにも慣れてきた頃ではないでしょうか？
また、昨今情報漏洩に関する関心が高まっているなかで、CORSやCSRF対策を見直している方もいるかも知れませんが、Nuxt(Vue)はSSRを扱っているなら知らなかったでは済まされない情報漏洩リスクである**クロスリクエスト状態汚染**(**cross-request state pollution**)というものがあります。

この記事では、どのような場合にクロスリクエスト状態汚染が起きるのか、そして防げるのかを解説していきます。

※タイトルでは「情報漏洩をさせる方法」と書きましたが、情報漏洩を助長したいわけではなく、そのリスクを理解したうえで安全なコードを書く方法をお伝えしたい記事になります。釣りタイトルの様になってますがご了承下さい。

## クロスリクエスト状態汚染とは

クロスリクエスト状態汚染を英語に直すと「cross-request state pollution」となり、薄眼で見たらCSRF(cross site request forgery)に見えなくもないですが全くの別物です。

まず、Vueの公式サイトに記載されている内容をそのまま引用します。
> SSR のコンテキストでは、アプリケーションモジュールは通常、サーバーが起動したときに一度だけ初期化されます。同じモジュール・インスタンスが複数のサーバー・リクエストにまたがって再利用されるので、シングルトンの状態オブジェクトも同じように再利用されます。あるユーザーに固有のデータを含む共有シングルトンの状態を変更した場合、誤って情報が他のユーザーからのリクエストに漏れてしまうことがあります。これを cross-request state pollution（クロスリクエスト状態汚染） と呼びます。

https://ja.vuejs.org/guide/scaling-up/ssr#:~:text=%E3%81%97%E3%81%8B%E3%81%97%E3%80%81SSR%20%E3%81%AE%E3%82%B3%E3%83%B3%E3%83%86%E3%82%AD%E3%82%B9%E3%83%88%E3%81%A7%E3%81%AF%E3%80%81%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%81%AF%E9%80%9A%E5%B8%B8%E3%80%81%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%8C%E8%B5%B7%E5%8B%95%E3%81%97%E3%81%9F%E3%81%A8%E3%81%8D%E3%81%AB%E4%B8%80%E5%BA%A6%E3%81%A0%E3%81%91%E5%88%9D%E6%9C%9F%E5%8C%96%E3%81%95%E3%82%8C%E3%81%BE%E3%81%99%E3%80%82%E5%90%8C%E3%81%98%E3%83%A2%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E3%83%BB%E3%82%A4%E3%83%B3%E3%82%B9%E3%82%BF%E3%83%B3%E3%82%B9%E3%81%8C%E8%A4%87%E6%95%B0%E3%81%AE%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%83%BB%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%81%AB%E3%81%BE%E3%81%9F%E3%81%8C%E3%81%A3%E3%81%A6%E5%86%8D%E5%88%A9%E7%94%A8%E3%81%95%E3%82%8C%E3%82%8B%E3%81%AE%E3%81%A7%E3%80%81%E3%82%B7%E3%83%B3%E3%82%B0%E3%83%AB%E3%83%88%E3%83%B3%E3%81%AE%E7%8A%B6%E6%85%8B%E3%82%AA%E3%83%96%E3%82%B8%E3%82%A7%E3%82%AF%E3%83%88%E3%82%82%E5%90%8C%E3%81%98%E3%82%88%E3%81%86%E3%81%AB%E5%86%8D%E5%88%A9%E7%94%A8%E3%81%95%E3%82%8C%E3%81%BE%E3%81%99%E3%80%82%E3%81%82%E3%82%8B%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AB%E5%9B%BA%E6%9C%89%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E3%82%92%E5%90%AB%E3%82%80%E5%85%B1%E6%9C%89%E3%82%B7%E3%83%B3%E3%82%B0%E3%83%AB%E3%83%88%E3%83%B3%E3%81%AE%E7%8A%B6%E6%85%8B%E3%82%92%E5%A4%89%E6%9B%B4%E3%81%97%E3%81%9F%E5%A0%B4%E5%90%88%E3%80%81%E8%AA%A4%E3%81%A3%E3%81%A6%E6%83%85%E5%A0%B1%E3%81%8C%E4%BB%96%E3%81%AE%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%8B%E3%82%89%E3%81%AE%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%81%AB%E6%BC%8F%E3%82%8C%E3%81%A6%E3%81%97%E3%81%BE%E3%81%86%E3%81%93%E3%81%A8%E3%81%8C%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82%E3%81%93%E3%82%8C%E3%82%92%20cross%2Drequest%20state%20pollution%EF%BC%88%E3%82%AF%E3%83%AD%E3%82%B9%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E7%8A%B6%E6%85%8B%E6%B1%9A%E6%9F%93%EF%BC%89%20%E3%81%A8%E5%91%BC%E3%81%B3%E3%81%BE%E3%81%99%E3%80%82

上記の記載内容だけみてもイメージがつかないかもしれませんので、一つずつ読み解いていきましょう。

### シングルトンパターン

> SSR のコンテキストでは、アプリケーションモジュールは通常、サーバーが起動したときに一度だけ初期化されます。同じモジュール・インスタンスが複数のサーバー・リクエストにまたがって再利用されるので、シングルトンの状態オブジェクトも同じように再利用されます

まず、上記のことをちゃんと理解するためにはシングルトンについて理解していく必要があります。
一言でいうと、シングルトン(singleton) とは「一度だけインスタンス化でき、どこからでもアクセスできるクラス・オブジェクト」のことです。この単一のインスタンスはアプリケーション全体で共有することができ、複数のサーバー・リクエストで再利用できることからグローバルな状態管理に適していると言えます。

では基本的なTypeScriptのプログラムでシングルトンの例を見ていきましょう。
※以下[フロントエンドのデザインパターン](https://zenn.dev/morinokami/books/learning-patterns-1/viewer/singleton-pattern)の引用を多く含みます。もっと詳しく知りたい方は第3章シングルトンパターンを参照ください。
```typescript
let counter = 0;

class Counter {
  getInstance() {
    return this;
  }

  getCount() {
    return counter;
  }

  increment() {
    return ++counter;
  }

  decrement() {
    return --counter;
  }
}
const counter1 = new Counter();
const counter2 = new Counter();
```
このCounterクラスはシングルトンではありません。シングルトンは一度しかインスタンス化できないはずですが、`new`メソッドを2回呼び出すことで`counter1`と`counter2`に異なるインスタンスを入れることができています。

このように複数回呼び出すことでインスタンスが異なったり初期化されるものはシングルトンにはならないということなので、関数についても同様のことが言えます。
```typescript
let counter = 0;

function increment() {
    console.log("Hello~~");
    counter++
    return counter;
}
increment();
increment();
```
increment関数についても複数回呼び出すことができますのでシングルトンではありません。
しかし、NuxtにおいてのSSRでは`counter変数`が（共有）シングルトンとなり、誤って情報がほかのユーザーに漏れてしまうパターンがあります。
その実例を次で見ていきましょう。

### 情報漏洩させてみよう

ここからは、Nuxt3のSSRで情報漏洩リスクのあるコードとはどういったものなのか、サンプルコードを見ながら解説していきます。
サンプルコードは以下のリポジトリにも格納しているのでこちらを見ながら、もしくはクローンして動かしながら確認することを推奨します。
また、Nuxt3及びTypeScriptの詳細については本記事では解説いたしませんのでご了承ください。

https://github.com/nojiritakeshi/nuxt3-cross-request-state-pollution

ではまず、`composables`配下に`moreru.ts`というファイルを作成し以下のコードを書いたとしましょう。
```ts:moreru.ts
const moreru = ref(0)
export const useMoreru = () => {
  const morenaide = ref(0)
  const incrementMoreru = () => {
    morenaide.value++
    moreru.value++
  }
  return { moreru, morenaide, incrementMoreru }
}
```
この`useMoreru`という関数は、グローバルに宣言された`moreru`というリアクティブ変数と`useMoreru`関数内に宣言された`morenaide`をインクリメントする`incrementMoreru`メソッドを持っています。

では、この`useMoreru`関数をページから呼び出してみましょう。
```ts:pages/index
<script setup lang='ts'>

const { moreru, morenaide, incrementMoreru } = useMoreru()
incrementMoreru()
console.log("moreru", moreru.value)
console.log("morenaide", morenaide.value)
</script>
<template>
  <div>
    <h1>Hello World</h1>
    <p>moreru: {{ moreru }}</p>
    <p>morenaide: {{ morenaide }}</p>
    <NuxtLink href="/about">About</NuxtLink>
  </div>
</template>
```
indexページでは、`useMoreru`を呼び出したうえで`incrementMoreru`を実行し、インクリメントした値をそれぞれロギング及びレンダリングをしています。

ここまでできたら`npm run dev`して開発サーバーを立ち上げましょう。

この時、クロスリクエスト状態汚染が分かりやすくするために、複数タブでlocalhost画面を開いておいてください。
![スクリーンショット 2024-08-15 004953.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838672/6c98ea96-29dd-9939-e87b-abe88e9091a1.png)

サーバーが起動しきったら、分割した画面の内左側の画面で開発者ツールを表示し、コンソールからロギングした結果を確認してみて下さい。
以下の様になっているのではないでしょうか？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838672/74390638-493d-1165-cc60-a8c1f5e3c48c.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838672/f889ee2d-d712-04c6-4bff-40efc0da73ad.png)

続いてSSR側でのロギングの結果も確認しておきましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838672/e9785f60-8464-0bd1-0663-0dc40eb8dc72.png)
この段階ではそれぞれ一度しか呼び出されていないのでSSRとブラウザ側での値には差はありません。

では、右側に分割した画面の出力結果を確認してみましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838672/d6ed6d08-36a2-5f63-9ad5-38f445d733a2.png)
画面の表示は先ほどと変わりませんね。SSR側でのロギングの結果はどうでしょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838672/925820a0-a766-ea4a-3cfe-5aecb174b103.png)
おや...？`moreru`が2になっていますね。
つまり、左側の画面で0⇒1にインクリメントした値に対して、さらにインクリメントしている（1⇒2）ということになります。

そう、つまりこれこそが**クロスリクエスト情報汚染**が起きているということなのです。

[NuxtSecurityのNuxt SSR で状態を慎重に扱う](https://nuxt-security.vercel.app/documentation/advanced/good-practices#handling-state-with-care-in-nuxt-ssr:~:text=%E3%83%9C%E3%83%BC%E3%83%89%E3%81%AB%E3%82%B3%E3%83%94%E3%83%BC-,Nuxt%20SSR%20%E3%81%A7%E7%8A%B6%E6%85%8B%E3%82%92%E6%85%8E%E9%87%8D%E3%81%AB%E6%89%B1%E3%81%86,-SSR%20%E3%83%A2%E3%83%BC%E3%83%89%E3%81%A7)でも言及されていますが、composable内のグロバール変数は共有シングルトン状態であるため、この値をリクエストごとに書き換えるようなコードを書いてしまうと、あるユーザーによって上書きされた値がまた別のユーザーに漏れてしまうといったことが起きてしまいます。
一方で`useMoreru`関数内に閉じ込めている`morenaide`変数は関数の呼び出し毎に初期化されるためシングルトンではありません。
実際サンプルでも、SSR側でもブラウザ側でも値が毎回1になっていますよね。

今回の例ではただの数値が漏れているだけなので危険性はないかもしれませんが、この値がユーザー固有の情報（指名、メールアドレス、住所など）だと考えるとゾッとしますよね...

## 事例

実際、クロスリクエスト状態汚染によって情報漏洩が起きてしまっている事例は存在しています。蒸し返すようなことになるとよくないので具体的な言及は避けますが、ググればいくつか事例は出てきますので、気になった方は調べてみてください。（実際謝罪文みると気が引き締まる気はしますw）


## まとめ

ここまで読んでくださりありがとうございました。

クロスリクエスト状態汚染についてサンプルコードを用いて説明してきましたが、サンプルコードの書き方だけ気を付けていれば全く問題ないのかというとそうではありません。あくまで一例だと思ってくださると幸いです。

## 参考リンク

https://zenn.dev/morinokami/books/learning-patterns-1/viewer/singleton-pattern

https://ja.vuejs.org/guide/scaling-up/ssr#vite-ssr

https://ja.vuejs.org/guide/scaling-up/state-management#simple-state-management-with-reactivity-api

https://nuxt-security.vercel.app/documentation/advanced/good-practices#handling-state-with-care-in-nuxt-ssr