12k
All articles

サーバーサイドでの画像アップロード処理

Nodeの画像アップロード安全対策: メモリ上でファイル署名を検証し、サイズ制限を設定し、sharpで再エンコードして安全に配信します。

OpenReplay Team
OpenReplay Team
サーバーサイドでの画像アップロード処理

サーバーサイドのファイルアップロード検証は、multipart リクエストが到着した時点から始まります。そしてその時点で信頼に値するのは、バイト列だけです。Content-Type もファイル名もブラウザから送られてくるものなので、事実ではなく「主張」として扱ってください。アップロード前の画像サムネイル生成Canvas による画像の Base64 変換といったブラウザ側の記事からたどり着いた方にとって、本記事はそのパイプラインの受け手側にあたります。

このパイプラインの前半と後半は、それぞれ異なる役割を担っています。ブラウザ側のリサイズや圧縮はユーザーへの配慮であり、サーバーサイドの検証こそが 2 GB のボディや .jpg 拡張子をまとったスクリプトを食い止めるものです。本記事では、Node の画像エンドポイントに必要な 5 つの制御、すなわちシグネチャチェック、多層的なサイズ制限、ファイル名の生成、再エンコード、そして安全な配信について解説します。

要点

  • multipart アップロードにおける Content-Type ヘッダーとファイル名は、いずれもクライアントが設定するものです。したがって、そのどちらかをチェックするサーバーは、ファイルそのものではなく、攻撃者による主張を検証しているにすぎません。
  • シグネチャチェックは、ディスクに何かが書き込まれる前に、メモリ上でファイルの先頭バイト列を読み取り、そのエンドポイントが受け入れるフォーマットと照合すべきです。
  • サイズ制限は、まずプロキシに、次に Multer の limits オプションに、最後にアプリケーションコードに置きます。Multer の fileSize はデフォルトが Infinity なので、未設定は無制限を意味します。
  • sharp による再エンコードこそが、シグネチャ偽装を無意味にします。出力は新しいファイルであり、スマートフォン写真の GPS 座標を含む EXIF メタデータはそこに残りません。
  • 保存した画像は、自前の検証記録から取得した Content-Type、X-Content-Type-Options: nosniff、そして web root の外に置いたストレージパスとともに配信してください。

MIME タイプと拡張子のチェックはなぜ機能しないのか

file.mimetype やファイル名の拡張子をチェックしても、ファイルの検証にはなりません。どちらの値もクライアントが提供するものだからです。当ブログの過去記事 2 本がまさにその助言をしていました。Multer NPM: File Upload in Node.js では mimetype が許可リストに含まれるファイルをすべて受け入れる fileFilter を紹介しており、Safe User Input Handling in Node.js では読者にパーサーレベルで MIME タイプを検証するよう伝えています。どちらのチェックも、安価な早期リジェクトとして残す価値はありますが、セキュリティ制御ではありません。

偽装は 1 行で済みます。

curl -F "file=@payload.sh;type=image/jpeg" https://example.com/upload

Multer はこの type の値をそのまま req.file.mimetype にコピーします。フィルターには image/jpeg に見えますが、実体はシェルスクリプトです。OWASP File Upload Cheat Sheet は、検証はクライアント提供のメタデータではなくファイルの内容に基づかなければならないと明言しています。

ファイルアップロードの検証はファイルシグネチャから始まる

ディスクに何かが書き込まれる前に、メモリ上でバッファの先頭バイト列を読み取り、そのエンドポイントが受け入れるフォーマットと照合してください。この一文の前半と後半は、どちらも重要です。チュートリアルではファイルがすでに uploads ディレクトリに着地した後で検証するものが一般的ですが、それではチェックが走る前に、サイズ超過や悪意あるペイロードがすでに被害をもたらしています。また、既知のシグネチャのいずれかに一致すれば通してしまうものも多く、その結果、アバター用エンドポイントを PDF が素通りします。PDF を認識するアバターエンドポイントは、機能ではなく検証のバグです。

シグネチャそのものについて。JPEG は FF D8 FF で始まり、PNG は PNG 仕様で定義された 8 バイト全体のシーケンスで始まります。WebP は WebP コンテナ仕様に従い、オフセット 0 に RIFF、バイト 8 から 11 に WEBP が必要です。バイト 4 から 7 は RIFF チャンクサイズであり、WebP のチェックがそこをスキップしなければならない理由であると同時に、素朴な 4 バイト読み取りが PNG も WebP も正しく扱えない理由でもあります。

const SIGNATURES = {
  "image/jpeg": (b) => b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff,
  "image/png": (b) =>
    b.length >= 8 &&
    b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])),
  "image/webp": (b) =>
    b.length >= 12 &&
    b.subarray(0, 4).toString("ascii") === "RIFF" &&
    b.subarray(8, 12).toString("ascii") === "WEBP",
};

// Allowlist is per endpoint: this one accepts photos, nothing else
function detectImageType(buffer, allowed = ["image/jpeg", "image/png", "image/webp"]) {
  return allowed.find((type) => SIGNATURES[type](buffer)) ?? null;
}

ただし 1 つ注意点があります。シグネチャは、任意のペイロードの先頭に適切なバイト列を付け足すだけで、数秒で偽装できます。file-type パッケージ自身の README にもそう書かれており、マジックナンバーの一致はフォーマットの証明ではなく手がかりにすぎないと説明されています。シグネチャチェックは、高速で安価なフィルターとして扱ってください。セキュリティ境界は、この 2 つ先のセクションにあります。

アップロードのサイズ制限はどこに置くべきか

サイズ制限は、まずプロキシ、次にフレームワーク、最後にアプリケーションコードに置きます。ボディがバッファリングされた後に走るチェックは、ペイロード全体がすでにメモリと帯域を消費した後になって初めてアップロードを拒否するからです。nginx では client_max_body_size のデフォルトが 1 MB で、サイズ超過のリクエストにはプロセスに届く前に 413 を返します。

client_max_body_size 5m;

フレームワーク層は Multer(2.x)です。limits.fileSize のデフォルトは Infinity なので、未設定は無制限を意味します。

const upload = multer({
  storage: multer.memoryStorage(),
  limits: { fileSize: 5 * 1024 * 1024, files: 1 },
});

上限に達すると、Multer は LIMIT_FILE_SIZE エラーを発生させます。Fastify では、@fastify/multipart が逆の立場をとり、fileSize のデフォルトを 1 MiB としています。つまり、安全な挙動がオプトインではなくデフォルトになっています。アップロードフローのセッションリプレイを見ると、プロキシ層を省略したコストが UX 上の不具合として可視化されます。プログレスバーが 100 パーセントに達してから拒否が表示されるのは、アプリケーションレベルのチェックが走る前にペイロード全体が到着しなければならないからです。

クライアントのファイル名は捨てる

クライアントのファイル名をファイルシステムに触れさせてはいけません。crypto.randomUUID() と、自前の検証で判定した拡張子から、自分でファイル名を生成してください。

const storedName = `${crypto.randomUUID()}.jpg`;

トラバーサルシーケンスから、path.normalize が防御にならない理由まで、その根拠は Preventing Path Traversal Attacks in Node.js で解説しています。ファイル名を生成するアプローチは、この攻撃クラス全体を到達不能にします。

画像は再エンコードし、元のバイト列は保存しない

再エンコードこそが、シグネチャ偽装を無意味にする制御です。sharp(0.35.x)はピクセルをデコードし、まったく新しいファイルを書き出すため、元のファイルの先頭や末尾に付加されたもの、あるいは内部に隠されていたものは一切残りません。さらに、プライバシー上の漏洩も塞ぎます。スマートフォンの写真は EXIF データに GPS 座標を含んでいることが日常的にあり、元のバイト列を保存するということは、ユーザーの位置情報を再公開することを意味します。sharp の出力ドキュメントはデフォルトの挙動を明記しています。keepExif()withMetadata() で明示的に要求しない限り、入力のメタデータは何一つ出力に引き継がれません。向き(オリエンテーション)のフラグも他のすべてと同様に失われるため、先に .autoOrient() で自動補正しないと、スマートフォンの写真は横倒しになります。

let clean;
try {
  clean = await sharp(req.file.buffer)
    .autoOrient() // apply EXIF orientation before metadata is stripped
    .jpeg({ quality: 85 })
    .toBuffer();
} catch {
  return res.status(422).send("Not a decodable image"); // decode failure is a rejection
}

シグネチャチェックを通過したのにデコードに失敗するバッファは、自身のフォーマットについて嘘をついていたということです。これはチェックが機能している証拠です。

受け取ったものではなく、検証したものを配信する

保存した画像を配信する際は、Content-Type をクライアントから送られてきた値ではなく、必ず自前の検証記録から設定してください。加えて X-Content-Type-Options: nosniff を付与してブラウザによる推測を封じ、ファイルは web root の外に保存して、アップロードされたものが直接実行されたりレンダリングされたりしないようにします。

app.get("/images/:id", async (req, res) => {
  const record = await getImageRecord(req.params.id); // contentType saved at validation time
  if (!record) return res.sendStatus(404);
  res.setHeader("Content-Type", record.contentType);
  res.setHeader("X-Content-Type-Options", "nosniff");
  res.sendFile(record.storedName, { root: UPLOAD_DIR }); // UPLOAD_DIR is outside the web root
});

隣接するトピックをいくつか、それぞれ 1 行で。

  • ウイルススキャン(ClamAV やクラウドの同等サービス)が重要になるのは、再エンコードされた画像ではなく、任意のドキュメントを受け入れる場合です。
  • アーカイブを受け入れるなら、展開前に解凍後のサイズを確認してください。zip 爆弾は、途方もなく膨張する小さなファイルです。
  • SVG はスクリプトを含みうる XML ドキュメントなので、画像エンドポイントからは完全に除外してください。
  • 署名付き URL のアーキテクチャはオブジェクトストレージへ直接アップロードしますが、これはこのパイプライン全体をアップロード後の処理ステップへ移すだけであり、なくすわけではありません。

まとめ

5 つの制御は 1 つのパイプラインを構成します。プロキシでサイズによって拒否し、メモリ上でそのエンドポイントの許可リストに対してシグネチャをチェックし、sharp で再エンコードし、自分で生成した名前で保存し、自分が制御するヘッダーとともに配信する、という流れです。

制御実行される場所何を防ぐか
サイズ制限まずプロキシ、次に Multer の limits、最後にアプリコードメモリと帯域を消費するサイズ超過のボディ
シグネチャチェックディスクに触れる前、メモリ上そのエンドポイントの許可リストに一致しないバイト列
sharp による再エンコード検証後、保存前偽装シグネチャ、隠されたペイロード、EXIF の GPS データ
ファイル名の生成保存時クライアントのファイル名を経由したパストラバーサル
検証済みの配信ヘッダー読み取りのたびMIME スニッフィングと web root からの実行

シグネチャチェックは安価にフィルタリングし、再エンコードはシグネチャが偽装されていても持ちこたえる境界です。まずは自分の Multer の設定を確認するところから始めてください。limits.fileSize が未設定なら、そのエンドポイントは現在、無制限のサイズのファイルを受け入れています。

FAQ

file-type npm パッケージは、手書きのシグネチャチェックの代わりになりますか?

代わりになるのはバイト比較であって、セキュリティモデルではありません。file-type は同じマジックナンバーを読み取りますが、その README はそれで得られるものについて率直です。一致は手がかりにすぎず、ファイルが本当にそのタイプなのか、正しく構成されているのかのいずれも保証しません。数百のフォーマットを認識するため、その上にエンドポイント固有の許可リストが依然として必要であり、真の境界はやはり再エンコードです。なお、このパッケージは ESM 専用なので、CommonJS プロジェクトでは動的 import か load-esm の回避策が必要です。

Multer の fileSize 制限は、クライアントが残りのファイルを送信するのを止めてくれますか?

確実には止めません。limits.fileSize に達すると Multer はバッファリングを停止して LIMIT_FILE_SIZE エラーを発生させ、これによりプロセスは無制限のメモリ使用から保護されますが、ネットワーク転送がキャンセルされることはドキュメントのどこにも保証されておらず、エラーが表面化するのが全バイト到着後になることもあります。帯域を実際に保護するのは nginx の client_max_body_size のようなプロキシレベルの上限であり、これこそが制限をまずプロキシに置くべき理由です。

クライアントが署名付き URL でオブジェクトストレージに直接アップロードする場合、どう検証しますか?

検証はなくなるのではなく、アップロード後のステップへ移ります。クライアントはどこからも配信されない隔離用バケットまたはプレフィックスにアップロードし、その後バックグラウンドワーカーやストレージトリガーの関数がオブジェクトをダウンロードして、同じシグネチャチェックと sharp による再エンコードを実行し、生成した名前でクリーンな出力を公開用の場所に書き込みます。検証に失敗したオブジェクトは削除し、隔離場所はブラウザに一切公開しません。

sharp による再エンコードは画像の色を変えてしまいますか?

広色域の画像では変わる可能性があります。何も指定しなければ、sharp はプロファイルを付けない sRGB の出力を返します。メタデータの除去によって埋め込みの ICC プロファイルも一緒に失われるためで、Display P3 や Adobe RGB で制作された写真はわずかに色が変化することがあります。EXIF データを復活させずに色を保持するには、エンコード前に keepIccProfile() を呼び出してください。keepMetadata() でもプロファイルは保持されますが、除去がユーザーを守っていた GPS 座標を含め、すべてが戻ってきてしまいます。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.