😨

[SvelteKit] SSR時はGlobal Storeを使うべからず

2024/08/23に公開

概要

  • サーバーサイド実行時にwritableをグローバル変数で宣言することは、情報流出につながる
  • SSRを利用する際は、load関数のスコープやcontext APIを使ってデータをやり取りすべし

Global Storeから情報流出が起こる仕組み

Svelteのstoreは簡単に状態管理を行うことができて便利ですが、SSR(サーバーサイド)で使う際は要注意です。

Case 1: 2人のユーザーが同時にリクエストを送信

例として、 +page.ts でユーザー情報を取得し、writableで作成したstoreに保存するコードを紹介します。このコードでは、ユーザーのリクエストをもとにバックエンドからユーザーデータを取得してuser storeに格納した後、 そのstoreを +page.svelte に渡しています。

import { writable } from 'svelte/store';
import type { PageLoad } from './$types';

export const user = writable();

export const load: PageLoad = async () => {
	// ユーザーデータをバックエンドから取得
	// ...
	user.set({ name: '太郎', embarrassingSecret: '恥ずかしい秘密' })

	return {
		user: user,
	}
};

ここで、太郎くんと次郎くんが同時にサービスにアクセスするケースを考えます。もし太郎くんのユーザーデータがuser storeにsetされてから +page.svelte に渡されるまでの間に、次郎くんのデータがuser storeにセットされてしまったらどうなるでしょう?

リクエスト2: 次郎のデータをバックエンドから取得
リクエスト1: 太郎のデータをバックエンドから取得
リクエスト1: 太郎のデータを user store にセット // storeには太郎のデータが格納中
リクエスト2: 次郎のデータを user store にセット // storeには次郎のデータが格納中
リクエスト1: user store を+page.svelteに返却 // storeには次郎のデータが格納中

答えは、「次郎くんの(恥ずかしい秘密が入った)ユーザー情報が太郎くんの画面に表示されてしまう」です。どうしてこのようなことが起こってしまうのでしょうか。原因は、user storeがin-memoryに存在し、かつグローバルにアクセスできてしまうことにあります。

同時リクエストのイメージ

SvelteKitのSSRはサーバー上で実行されます。そして、user storeはin-memory上に存在し続けます。また、user storeはグローバル変数、global storeとして宣言されており、どの関数やファイルからでもアクセスできる状態にあります。

今回のケースでは、太郎くんのデータを取得しようとするリクエスト1と、次郎くんのデータを取得しようとするリクエスト2が同時にこのuser storeを参照・更新してしまったため、このような悲劇が起こってしまいました。

Case 2: 2人のユーザーが異なるタイミングでリクエストを送信

では同時にリクエストを受け取らなければGlobal Storeを使っても大丈夫なのか?と思われるかもしれませんが、そんなことはありません。例として記事投稿サービスを考えます。下記のコードを見てください。

// src/lib/global_state.ts
export const sessionToken = writable<string>('some-session-token')

// src/lib/login.ts
async function login(...) {
	/// ログイン処理でセッショントークン生成
	/// ...
	sessionToken.set(token)
}

// src/lib/post_article.ts
async function postArticle(title: string, content: string) {
	// ユーザー情報を取得
	let _sessionToken = ''
	sessionToken(t => _sessionToken = t)()
	const u = await getUser(_sessionToken)
	
	// 記事を投稿
	post(u.name, title, content)
}

このコードではセッショントークンをglobal storeとして宣言しています。login()では、global storeであるsessionTokenに新しいトークンをセットし、他の関数やファイルから参照できるようにします。

postArticle()では、sessionTokenにセットされているトークンを使用してユーザー情報を取得し、そのユーザーの名前とともに記事を投稿します。

SSRをオフにしている場合、このコードは常にブラウザ上で動作するため、login()とpostArticle()は常に同じユーザーによって作動させられることが保証されています。そのため、情報流出や想定外の動作が起こる心配はありません。

一方、SSRをオンにしている場合は、大事故が発生します。例えば太郎くんがログインを行なった後にアプリを閉じます。このとき、サーバーのin-memoryに存在しているsessionTokenには太郎くんのトークンがセットされたままです。

次に、次郎くんが「うんちぶりぶり」という内容の記事を投稿します。postArticle()はsessionTokenからトークンを取得し、記事の投稿処理を行います。

さて、このときsessionTokenにセットされていたのは太郎くんのトークンです。そのため、アプリ上には太郎くんの名義で「うんちぶりぶり」という投稿が表示されてしまいました。不適切な投稿をしたのは次郎くんにもかかわらず、太郎くんはウンコマンの汚名を着せられコミュニティから除け者にされてしまいました。

このように、異なるユーザーのリクエストが同時でなかったとしても、SSR時のGlobal Store使用は予期せぬ動作を発生させ、甚大な被害を生み出すリスクを伴います。

実はこの問題は結構前から指摘されており、GitHubでも盛んに議論されています。

Sharing a global variable across multiple requests is unsafe in SSR · sveltejs kit · Discussion #4339

この問題に対するSvelteKitの回答は、「store (writable)をグローバル変数として使うな」です。SSR時にデータを受け渡す方法については、いくつかの代替案が提示されています。

SvelteKit docs

代替案

  • load関数から値を返却
  • Svelteの context API を使用する

1. load関数から値を返却

+page.ts のload関数でデータを取得後、そのまま +page.svelte に渡します。ここで宣言した変数はすべてload関数のスコープ内で完結するので、他のファイルや関数からアクセスすることは不可能です。+page.svelteに渡されたデータも、レスポンス返却後はGarbage Collectionによって破棄されるため、ユーザーデータがin-memoryに残ることはありません。 +page.ts+page.svelte はリクエストごとにコンテキストが区切られているため、先のような情報流出や想定外の被害が起こる心配はありません。

import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch }) => {
	// ユーザーデータをバックエンドから取得
	const resp = await fetch('https://...')

	return {
		user: await resp.json(),
	}
};

この方法は安全ではありますが、深い階層のコンポーネントや関数に引数を渡したい時はバケツリレーをしなければならず、少々面倒です。この場合は、次に紹介するcontext APIを使用します。

2. Svelteの context API を使用する

setContext(), getContext() を使用することで、中間のコンポーネントにバケツリレーをさせることなく、親コンポーネントから子・孫コンポーネントにデータを渡すことができます。

親コンポーネント

<!-- +page.svelte -->
<script lang="ts">
	import type { PageData } from "./$types";
	import { setContext } from "svelte";

	export let data: PageData;

	setContext('user', data.user)
</script>

<slot />

子コンポーネント

<!-- SomeChildComponent.svelte -->
<script lang="ts">
	import { getContext } from "svelte";

	const user = getContext('user')
</script>

データ受け渡しのためだけにpropsをexportする必要がないので、コードがスッキリしますね。

おわりに

先に紹介したSvelteKitのサイトでも触れられていますが、SSRのためというだけでなく、保守性を向上させる観点からも、グローバル変数を用いた状態管理は避けるのが無難だと思われます。

とはいえ、CSR時のパフォーマンス向上を目的としたin-memoryキャッシュなど、どうしてもGlobal Storeを使いたい場面はあります。今後機会(とやる気)があれば、SSR使用時でも安全なGlobal Storeの使用例やプロジェクトの設計方法を紹介したいと思います。

宣伝

ビジネスアイデアの生成や起業家秘話の投稿・閲覧ができる Giants を運営しています。よかったら遊びに来てね

Discussion