メインコンテンツへスキップ
kt-tech.blog

【設計】コードレビューで見つけたセキュリティ・並行処理の落とし穴と修正アプローチ

設計5分で読めます

この記事でわかること

  • DB に入っている値をファイルパスに使うときの Path Traversal と、path.basename() での回避
  • async 処理の途中で useRef の値が入れ替わり、別セッションに書き込む Race Condition
  • early return だけで済ませるとユーザーが詰む理由
  • useQuery の enabled を忘れたときに起きること

React(hooks)と TypeScript を書いたことがあること。Next.js のコードを例にしています。

メモ
チャットアプリのPRレビューで発見した、見落としやすいセキュリティ脆弱性と並行処理のバグ、その修正パターンを紹介します。

1. Path Traversal — DBの値を信用しない

問題

TypeScript
// ❌ DBから取得したファイル名をそのままpath.joinに渡している
const promptFileName = character.promptFile;
const promptPath = path.join(process.cwd(), "src/prompts", promptFileName);
fs.readFileSync(promptPath, "utf-8");
// character.promptFile が "../../etc/passwd" だったら?

修正

TypeScript
// ✅ path.basename() でディレクトリトラバーサルを防止
const promptFileName = path.basename(character.promptFile);
const promptPath = path.join(process.cwd(), "src/prompts", promptFileName);
重要
path.basename()"../../etc/passwd" から "passwd" だけを取り出します。DB の値だから安全、とは限りません。そのレコードを入れたのが管理画面やインポート処理なら、元をたどれば外部入力です。ファイルパスに使う値は出所を問わず正規化するのが安全です。

2. Race Condition — useRefの値がasync中に変わる

問題

TypeScript
// ❌ async処理中にsessionIdRef.currentが別のセッションに上書きされる
const sendMessage = useCallback(async (content: string) => {
  if (!sessionIdRef.current) return;

  await saveMessage(sessionIdRef.current, "user", content);
  const { text } = await getChatResponse(content);  // 数秒かかる
  // ↑ この間にキャラ変更 → sessionIdRef.current が変わる
  await saveMessage(sessionIdRef.current, "assistant", text);  // 別セッションに保存!
}, []);

修正

TypeScript
// ✅ 関数冒頭でローカル変数にコピー
const sendMessage = useCallback(async (content: string) => {
  const currentSessionId = sessionIdRef.current;  // ここで固定
  if (!currentSessionId) return;

  await saveMessage(currentSessionId, "user", content);
  const { text } = await getChatResponse(content);
  await saveMessage(currentSessionId, "assistant", text);  // 安全
}, []);

3. サイレント失敗 — エラーなのにUIが何も言わない

問題

TypeScript
// ❌ キャラが見つからない → early return → ユーザーには何も見えない
const character = await getCharacterBySlug(selectedSlug);
if (!character) return;  // 永遠にチャットが使えない状態に

修正

TypeScript
// ✅ デフォルトキャラにフォールバック
const character =
  (await getCharacterBySlug(selectedSlug)) ??
  (await getCharacterBySlug(DEFAULT_CHARACTER_SLUG));
if (!character) return;  // 両方なければ諦める(ありえないはず)
Tips
if (!x) return; は書く側には楽ですが、ユーザーから見ると「何も起きない」ので、壊れているのか待てばいいのか判断できません。フォールバックするか、せめてエラーを画面に出すかのどちらかにしましょう。

4. useQueryのenabled未設定 — 不要なリクエスト

TypeScript
// ❌ session未取得時にもクエリが走る
const { data } = useQuery({
  queryKey: ["characters", session?.user?.id],
  queryFn: () => getCharacters(session?.user?.id),  // undefined で呼ばれる
});

// ✅ enabled で制御
const { data } = useQuery({
  queryKey: ["characters", session?.user?.id],
  queryFn: () => getCharacters(session?.user?.id),
  enabled: !!session?.user?.id,  // userIdがあるときだけ
});

まとめ

  • DBの値でもファイルパスに使うなら path.basename() で正規化する
  • async関数内でuseRefを使うなら、冒頭でローカル変数にコピーする
  • early returnにはフォールバックを用意し、ユーザーが詰む状態を作らない
  • useQueryenabled で不要なフェッチを防ぐ

参考リンク

更新履歴

  1. コード例に入っていたプロダクト固有の名前を定数に差し替え。「DB の値なら安全」とは限らない理由を補足。early return がユーザーからどう見えるかの説明を追加。callout の色指定を他記事と揃え、メソッド名をインラインコードに統一。参考リンクの節を新設。

この記事のタグ