security/handler-state-write · handler から import した状態への書き込み
load 関数や action が、import したモジュールの状態に書き込んでいます。サーバーではこの状態を全リクエストで共有します。
重大度: critical · カテゴリ: security
チェック内容
サーバーで実行される handler(load、form action、+server の HTTP handler、hooks.server の handler)の内部から、import した binding への書き込みを検出します。対象はプロパティ代入(state.user = …)、インクリメント/delete、.set(...) / .update(...) 呼び出しです。
universal な +page.ts/+layout.ts の load も対象です。SSR 時はサーバーで実行されるためです。
検出しないもの:
- 同じファイルで
ssr = falseを export している universal な+page.ts/+layout.ts。ルートがサーバーでレンダリングされない universal ファイルも対象外です。ページ自身の+pageファイル、続いてレイアウトチェーン(+page@/+layout@によるリセットに従います)の順にたどり、最も近いssrの export がssr = falseなら、そのルートはサーバーでレンダリングされません。svelte.config.jsでkit.router.type: 'hash'を指定したアプリでは、どのルートもサーバーでレンダリングされません。ssr = dev(devは$app/environmentのもの)は、本番ビルドでの値であるfalseとして扱います。それ以外の値で export されたssrは、SSR を再び有効にするものとみなします。同じ階層の universal モジュール(+page.ts/+layout.ts)と server モジュールの両方が export している場合は、SvelteKit と同じく universal 側を採用します。レイアウトは配下のすべてのページがこれに当たる場合に限り、ルートレイアウトは自身がssr = falseを export する場合に限り対象外です。その load はサーバーで実行されないため、リークする共有インスタンスがそもそも存在しません。+page.server.tsはssrの値に関わらず常にサーバーで実行されるため、server 系のファイルはこの除外の対象外です。 - 読み取り、その他のメソッド呼び出し(
logger.info(…))、ローカル変数への書き込み。 - ブラウザでしか実行されない書き込み。
if (!browser) return;のような早期 return の後、if (browser)の中、browser && …の右辺にあるものです(browserは$app/environmentのもの、またはtypeof windowの判定)。ブラウザでは、訪問者ごとにモジュールのコピーを持つためです。ガードのサーバー側にある書き込み(if (!browser) user.set(…))は引き続き報告します。 - インストール済みパッケージからの import への
.set()/.update()。 - 引数が更新関数(関数リテラル、またはモジュール内で関数に束縛した名前)でない
.update()。ストアのupdateは更新関数を受け取りますが、データベースクライアントのupdateはテーブルやオプションを受け取ります(prisma.post.update({ … })、db.update(table))。クライアントをどこから import していても同じです。 - 解決先が
src/lib/serverになる 永続化クライアントへの.set()/.update()。ディレクトリエントリポイント(import { db } from '$lib/server')とsrc/lib/server/**配下が該当します。Drizzle のdb.update(...).set(...)のような呼び出しは永続化であり、共有状態への書き込みではないためです。
src/lib/server の除外は解決後のパスに対して働くので、$lib/server/ alias 経由でも相対パス(../../lib/server/db)経由でも同じように適用されます。.. でプロジェクトルートの外へ抜ける specifier は、保守的にリポジトリ内の共有状態としては扱いません。
ただし除外の条件はディレクトリだけではありません。svelte-vitals は対象モジュールを読み、export がインメモリコンテナでない場合にのみ除外します。new Map/Set/WeakMap/WeakSet、あるいはオブジェクト・配列リテラルで初期化された export は自作ストア(リクエストごとに上書きされる単一の共有インスタンス)なので、src/lib/server 配下でも検出します。プロパティがすべて関数(モジュール内の関数や関数リテラル。export const data = { get, set })のオブジェクトリテラルはデータを持たないので、ストアとは見なしません。その .set() は自身のメソッドの呼び出しです。ただし spread で組み立てたオブジェクトリテラル({ ...models, ...handlers })は例外です。たいていはデータベースクライアントや import したモジュールをまとめたファサードなので、自身のプロパティのどれかがコンテナである場合({ ...defaults, hits: new Map() })に限りストアとみなします。
export const db = new Map(); // handler から db.set(...) すると検出
export const client = drizzle(url); // コンテナリテラルではないので除外
コンテナだと断定できないものはすべて除外のままなので、実クライアントのラッパーや re-export、読めないモジュールが誤検知になることはありません。読み込むのは handler が実際に書き込んでいるモジュールだけです。
src/lib/server の外では、既定の扱いが逆になります。handler からリポジトリ内の import への .set()/.update() は、対象モジュールがその binding をクライアントだと示さない限り検出します。クライアントとみなすのは次の 2 つです。1 つはクラスの new で初期化した export で、クラスはインストール済みパッケージから import したものか、そのモジュールで宣言され何も継承せず Map/Set/WeakMap/WeakSet のフィールドも持たないものに限ります(export const kv = new KvService())。もう 1 つはインストール済みパッケージの関数の呼び出しで初期化した export です(drizzle(url) や createClient(…))。パッケージかどうかは、アプリの kit.alias を先に解決してから判断します。svelte/store や名前に store を含むパッケージのストア生成関数はクライアントではないので、export const user = writable(null) は引き続き検出します。
なぜ重要か
SvelteKit の状態管理ドキュメントが「NEVER DO THIS」と明記するパターンです。サーバーは、全ユーザーが共有する長寿命の1プロセスです。あるリクエスト中に書き込まれたモジュール状態は、次のリクエストが来てもそこに残っています。そこにリクエストごと・ユーザーごとのデータが入っていれば、あるユーザーのデータが別のユーザーに漏れる可能性があります。開発中はユーザーが1人なので完璧に動き、本番で静かに壊れます。
ただし、検出されたすべての書き込みがリークとは限りません。IP やURL、キャッシュキーのような個人に紐づかない値をキーにしたレートリミッターやメモ化キャッシュは、無害な形です。ユーザー間でデータを共有すること自体が設計上の目的であり、それは問題ありません。同じ呼び出し形からどちらも生まれるため、この検出は書き込みそのものに対して機械的に発火します。どちらに該当するかはレビューして確認してください。
修正方法
保存せず、データを返します。
import { user } from '$lib/user';
export async function load({ fetch }) {
const response = await fetch('/api/user');
user.set(await response.json()); // ❌ サーバー上では全リクエストで共有される
return { user: await response.json() }; // ✅ リクエストごとの page data
}
ユーザー別のデータは cookies/locals とデータベースに置き、load したデータは page.data か context API でコンポーネントに渡します。書き込みが本当に、個人に紐づかないデータをキーにしたレートリミッターやメモ化キャッシュなら、その直前に // svelte-vitals-disable-next-line security/handler-state-write を書いてください。
モードによる違い
ありません。このルールが読むのは同じ .svelte / .ts のソースファイルなので、CLI、Vite プラグインのビルド、ライブダッシュボードの静的ベースラインのいずれでも結果は同一で、レンダリング済み HTML の解析で再評価されることもありません。--route で実行範囲を絞ると、このルールは動きません。コンポーネントスコープのルールには、検出を紐づけるルートが無いためです。
無効化
個別に抑制するには、対象行の直前に <!-- svelte-vitals-disable-next-line security/handler-state-write --> を置きます。ルールごと無効化するには、次のように設定します。
export default {
rules: {
'security/handler-state-write': 'off'
}
};