Sveltekit - Storeからの情報漏洩の可能性について
はじめに
サーバーでは state の共有を避ける
ブラウザは state を保持します(Browsers are stateful) — ユーザーがアプリケーションとやりとりする際に、state はメモリ内に保存されます。一方、サーバーは state を保持しません(Servers are stateless) — レスポンスの内容は、完全にリクエストの内容によって決定されます。概念としては、そうです。現実では、サーバーは長い期間存在し、複数のユーザーで共有されることが多いです。そのため、共有される変数にデータを保存しないことが重要です。
引用: https://kit.svelte.jp/docs/state-management#avoid-shared-state-on-the-server
公式サイトを見てるとこのように State をサーバー側では共有しないように注意しています。Long-lived なサーバーではメモリーに保存した値がそのまま残ってしまうからです。
実際どのようにしたらデータが共有され、情報漏洩の危険になってしまうかを検証したいと思います。
本来はGlobalなState管理に import { writable } from 'svelte/store';
というAPIが提供されていますが、せっかくなので今回は Svelte 5のRuneを使って記述していきたいと思います。
見た目は sveltekitをinitした状態を少し触ったものを使用します。またスタイルなどは今回触れません。
Storeを Rune で作ってみる
早速 Store を作ってみましょう。
// counterStore.svelte.ts
let count = $state(0);
export function createCounter() {
return {
get count() {
return count;
},
increment: () => (count += 1),
decrement: () => (count -= 1)
};
}
簡単ですね。 このファイルを読み出すと let count = $state(0);
が memory上に保存され global的にアクセスできるようになります。
試しに ブラウザ側で呼び出してみます。Browser側で couterStore
を呼び出す用の CounterBrowser.svelte
を用意して store を呼び出します。
// CounterBrowser.svelte
<script lang="ts">
import { createCounter } from '$lib/countStore.svelte';
const counter = createCounter();
</script>
<div class="counter">
<button onclick={counter.decrement}>
<svg aria-hidden="true" viewBox="0 0 1 1">
<path d="M0,0.5 L1,0.5" />
</svg>
</button>
<div class="counter-viewport">
<div class="counter-digits">
<strong class="hidden" aria-hidden="true">{Math.floor(counter.count)}</strong>
<strong>{Math.floor(counter.count)}</strong>
</div>
</div>
<button onclick={counter.increment}>
<svg aria-hidden="true" viewBox="0 0 1 1">
<path d="M0,0.5 L1,0.5 M0.5,0 L0.5,1" />
</svg>
</button>
</div>
2つのブラウザを開いて挙動を確認すると片方のみグローバルで値が反映されてます。コンポネントを2回表示してグローバルなstoreであることを確かめます。しかし値はブラウザーのメモリに格納されるのでもう片方のブラウザーには反映されません。
共有された State の検証
では実際にこの Store State を Server side で stateが共有される形で使用してみます。
State の更新は Server側で行いたいので sveltekit の form action を使用して更新を試みる形にします。
// +page.server.ts
import { createCounter } from '$lib/countStore.svelte';
import type { Actions } from './$types';
export const load = async () => {
// Server側で store を呼び出す
const counter = createCounter();
return {
// フロント側に値を渡す
count: counter.count
};
};
// Store の更新は form action を使う
export const actions = {
increment: async () => {
const counter = createCounter();
counter.increment();
return {};
},
decrement: async () => {
const counter = createCounter();
counter.decrement();
return {};
}
} satisfies Actions;
Form action を実行するために先ほどの CounterBrowser.svelte
を少し改良して Counter.svelte
を作成します。
// Counter.svelte
<script lang="ts">
import { enhance } from '$app/forms';
type Props = {
count: number;
};
let { count }: Props = $props();
</script>
<div class="counter">
<form method="POST" action="?/decrement" use:enhance>
<button type="submit">
<svg aria-hidden="true" viewBox="0 0 1 1">
<path d="M0,0.5 L1,0.5" />
</svg>
</button>
</form>
<div class="counter-viewport">
<div class="counter-digits">
<strong class="hidden" aria-hidden="true">{Math.floor(count)}</strong>
<strong>{Math.floor(count)}</strong>
</div>
</div>
<form method="POST" action="?/increment" use:enhance>
<button type="submit">
<svg aria-hidden="true" viewBox="0 0 1 1">
<path d="M0,0.5 L1,0.5 M0.5,0 L0.5,1" />
</svg>
</button>
</form>
</div>
各+-のボタンをクリックするとそれぞれの form action をリクエストします。Form actionが実行されると自動的に load functionが再実行されて、値の更新がされます。このコンポネントを +page.svelte
で呼びます。(余分なコードは消してます)
// +page.svelte
<script>
import Counter from './Counter.svelte';
let { data } = $props();
</script>
<section>
<Counter count={data.count} />
</section>
では実際に Sessionが違うブラウザーを2つ開いて挙動を見てみます。
更新後、別のセッションでページを更新すると値が更新されます。Stateが共有されていることがわかりました。
これは 別セッションのブラウザでも Server は共有されたものを使用してるからです。
使い方に気をつけなければユーザーなどの個人情報が漏洩される危険があります。
まとめ
SvelteのState管理は非常に強力で使いやすいですが、SvelteKitのSSR時などは十分に気をつける必要があります。
Log-livedなServerではStateが共有されてしまうので、お客様の情報が他人に漏洩してしまう可能性があります。
対策として Svelteから Context API が提供されてるので、 User Id を key に storeを保存する方法などがありますが、公式通りに使わない方が安全かもしれません。