コンテンツにスキップ
svelte-vitals
Esc
↑↓移動↵開く⌘Jプレビュー
このページの内容

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'
  }
};