プログラマのための IT 教科書

ファイルとストレージ

この部の 5 / 13 章 ・ 全体で 47 / 76 章 ・ 読了目安 40 分

この章を読むとできるようになること
  • アップロードを信頼できない入力として扱える
  • 署名付き URL で安全に配信できる
  • 削除と孤児ファイルの整合性を設計できる

「プロフィール画像をアップロードできるようにして」

実務で必ず来る依頼です。そして、見た目より論点が多い機能でもあります。

□ どこに保存するか
□ サイズと形式をどう制限するか
□ 偽装されたファイルをどう弾くか
□ ファイル名をどうするか
□ 他人の画像が見えないようにするには
□ 削除したはずのファイルが残っていないか

この章では、ファイルを扱う時の定石を扱います。

データベースに入れない

悪い: 画像を BLOB として DB のカラムに保存する

技術的には可能ですが、実務では避けます。

問題内容
サイズDB が肥大化し、バックアップとリストアが極端に遅くなる
コストDB のストレージは、オブジェクトストレージより高価
配信CDN から直接配信できない。毎回アプリを経由する
メモリ取得のたびにアプリのメモリに載る(プロセスとメモリ)

ファイルはオブジェクトストレージへ、DB にはその場所(キー)だけを持ちます。

CREATE TABLE user_avatars (
  user_id     TEXT PRIMARY KEY,
  object_key  TEXT NOT NULL,   -- 'avatars/01J.../original.webp'
  content_type TEXT NOT NULL,
  size_bytes  BIGINT NOT NULL,
  uploaded_at TIMESTAMPTZ NOT NULL
);

オブジェクトストレージの考え方

Cloud Storage / S3 などは、ファイルシステムではありません。

実体:  「キー(文字列)」→「バイト列」の巨大な連想配列
見え方: スラッシュ区切りのキーが、フォルダのように表示されているだけ

だから、

□ 「ディレクトリの移動」は存在しない(コピーして削除する)
□ 大量のキーの一覧取得は遅い(プレフィックス設計が重要)
□ 追記や部分更新はできない(丸ごと置き換える)

キーの設計は最初に決めてください。後から変えるのは大変です。

avatars/{userId}/{uuid}.webp
orders/{yyyy}/{mm}/{orderId}/invoice.pdf

アップロードの方式

[方式A] ブラウザ → アプリサーバー → ストレージ
[方式B] ブラウザ → ストレージ(署名付き URL で直接)
方式A(サーバー経由)方式B(署名付き URL)
実装単純1手順増える
サーバー負荷大きい(帯域とメモリを消費)ほぼ無い
大きなファイルタイムアウトしやすい強い
検証サーバーで完結できるアップロード後に検証が必要

大きなファイルを扱うなら方式Bが定石です。

1. クライアントが「アップロードしたい」とアプリに要求する
2. アプリが権限を確認し、期限付きの署名付き URL を発行する
3. クライアントがその URL に直接 PUT する
4. 完了をアプリに通知する(またはストレージのイベント通知を使う)
5. アプリが検証し、DB に記録する
署名付き URL の発行時に権限を確認する

署名付き URL は、それを持っている人なら誰でも使えます。

□ 発行時に、その利用者がその操作をしてよいか必ず確認する(認証と認可の実装)
□ 有効期限を短くする(数分〜十数分)
□ アップロード用は PUT のみ、参照用は GET のみに限定する
□ キーを利用者に自由に指定させない(後述)

検証 — ここが本題

アップロードされたファイルは、セキュリティの「信じてはいけない入力」そのものです。

拡張子と Content-Type を信じない

attack.jpg  という名前で、中身は PHP スクリプト
Content-Type: image/png  と宣言して、中身は実行ファイル

どちらもクライアントが自由に名乗れます。

// 先頭のバイト列(マジックナンバー)で実際の形式を判定する
head := make([]byte, 512)
if _, err := io.ReadFull(r, head); err != nil { return err }
detected := http.DetectContentType(head)
 
if !allowedTypes[detected] {
    return fmt.Errorf("許可されていない形式です: %s", detected)
}

さらに、画像なら実際にデコードしてみるのが確実です。 デコードできないものは、画像ではありません。

サイズ制限は必ず入れる

// Go: 読み取る量そのものを制限する(先に上限を超えたら止まる)
r.Body = http.MaxBytesReader(w, r.Body, 10<<20)   // 10MB
「画像爆弾」に注意

サイズ制限だけでは足りません。

100KB の PNG ファイル
  → 展開すると 50,000 × 50,000 ピクセル
  → メモリ上で数十 GB になり、プロセスが落ちる

圧縮された小さいファイルが、展開すると巨大になる攻撃です (zip 爆弾も同じ原理)。

□ 縦横のピクセル数にも上限を設ける
□ 変換処理は別プロセス・別コンテナで行い、メモリ上限を設定する
□ 変換のタイムアウトを設ける

ファイル名を信じない

アップロードされたファイル名: ../../../etc/passwd
アップロードされたファイル名: .env

利用者が指定した名前を、そのままキーに使ってはいけません(パストラバーサル)。

// 保存キーは自分で生成する。元のファイル名は「表示用」として別に持つ
key := fmt.Sprintf("avatars/%s/%s%s", userID, uuid.NewString(), ext)

これで、上書き事故・衝突・パス操作がまとめて防げます。

実行可能な形で配信しない

□ アップロードされたファイルを、アプリと同じドメインで配信しない
   (HTML や JS が置かれると、そのドメインで XSS が成立する。セキュリティ)
□ Content-Disposition: attachment を付ける
□ 元の Content-Type をそのまま返さない(サーバー側で決めた値を返す)
□ 可能なら、別ドメイン / 別バケットから配信する
公開バケットの事故

設定ミスで全世界に公開されていた、というのはクラウドで最も多い事故の1つです。

□ 既定は非公開にする
□ 公開が必要なものだけ、明示的に公開バケットへ置く
□ 非公開ファイルへのアクセスは、期限付きの署名付き URL 経由にする
□ バケットの公開設定を、定期的に確認する仕組みを持つ

「URL を知っている人しかアクセスできないから安全」は、 セキュリティではありません(推測される、共有される、検索される)。

画像の変換は非同期で

アップロード完了 → メッセージを publish(非同期処理とメッセージング)
                        ↓
              変換ワーカーがサムネイルを作る

同期的に変換すると、リクエストが数秒〜数十秒かかります。 アップロードと変換は分けてください。

□ 変換中の状態を持つ(processing / ready / failed)
□ 変換が終わるまでは、元画像かプレースホルダを表示する
□ 変換の失敗も記録する(無言で失敗させない)

配信とキャッシュ

□ CDN を通す(性能と負荷対策)
□ 内容が変わったら URL も変える(キーに UUID やハッシュを含める)
□ 個人向けのファイルを CDN でキャッシュしない(性能と負荷対策の事故)
上書きせず、新しいキーで作る

同じキーに上書きすると、CDN やブラウザのキャッシュが古い画像を返し続けます。

悪い: avatars/{userId}.jpg を上書きする
良い: avatars/{userId}/{uuid}.jpg を新規作成し、DB のキーを差し替える

古いファイルは、後からライフサイクルで削除します。

削除とライフサイクル

DB とストレージという2つのシステムがあるため、非同期処理とメッセージングと同じ整合性の問題が起きます。

DB のレコードは消えたが、ファイルが残っている  → 孤児ファイル(課金され続ける)
ファイルは消えたが、DB のレコードが残っている  → 表示時に404

現実的な方針は、こうです。

1. まず DB のレコードを消す(利用者から見えなくなる)
2. ファイルの削除は、後追いのバッチで行う(バッチとジョブ)
   → DB に存在しないキーを、一定期間後に削除する

「利用者に見えない」ことを先に達成し、実体の削除は後で確実にやる—— これが定石です。

法的な削除要求との関係

エンジニアと法律のとおり、削除要求への対応が必要な場合があります。

□ バックアップやバージョニングに残ったコピーをどう扱うか
□ CDN のキャッシュはいつ消えるか
□ 「消したつもり」で残っていないか

設計の段階で「どう消すか」を決めるのは、ここでも同じです。

□ ライフサイクルルールで、一時ファイルは自動削除する
□ アクセスされないファイルを安価なクラスへ移す(コスト。クラウドの基礎)
□ バージョニングを有効にすると、消したつもりのものが残る点に注意する

大きなファイル

□ マルチパートアップロード(分割して送り、途中から再開できる)
□ 未完了のマルチパートは課金対象。ライフサイクルで自動削除する
□ ダウンロードはストリーミングで返す(全部メモリに載せない。プロセスとメモリ)

プロフィール画像のアップロード機能で、セキュリティ上まず確認すべきことは?

実務の落とし穴まとめ

  1. DB にファイルを入れる — バックアップとコストで詰む
  2. 拡張子・Content-Type を信じる — どちらも偽装できる
  3. サイズ制限だけ — 画像爆弾でメモリが吹き飛ぶ
  4. ファイル名をキーに使う — パストラバーサル・上書き・衝突
  5. アプリと同じドメインで配信 — XSS が成立する
  6. バケットを公開設定にしてしまう — クラウド事故の定番
  7. 同じキーに上書き — キャッシュが古いまま残る
  8. 変換を同期で行う — リクエストが詰まる
  9. 削除の整合性を考えない — 孤児ファイルが課金され続ける
  10. 未完了のマルチパートを放置 — 見えないのに課金される

まとめ

  • ファイルはオブジェクトストレージ、DB にはキーだけ
  • 大きなファイルは署名付き URL で直接。ただし発行時に権限を確認する
  • アップロードは信頼できない入力。 実バイト列で形式判定・サイズとピクセル数の制限・キーはサーバーが生成
  • アプリと同じドメインで配信しない。既定は非公開
  • 変換は非同期(非同期処理とメッセージング)。状態を持つ
  • 上書きせず新しいキーを作る。キャッシュが古くならない
  • 削除はDB を先に、実体は後追いのバッチで(バッチとジョブ)
  • ライフサイクルで一時ファイルと未完了アップロードを自動削除する

公式ドキュメント

迷ったら一次情報に戻ってください。

章末問題

非公開の請求書 PDF を、利用者に安全にダウンロードさせたい。最も適切なのはどれですか。

次の章では、この変換処理のようなすぐに返さなくてよい仕事を、 どう別の流れに逃がすかを扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。