ファイルとストレージ
この部の 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 は、それを持っている人なら誰でも使えます。
□ 発行時に、その利用者がその操作をしてよいか必ず確認する(認証と認可の実装)
□ 有効期限を短くする(数分〜十数分)
□ アップロード用は 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 のキャッシュはいつ消えるか
□ 「消したつもり」で残っていないか
設計の段階で「どう消すか」を決めるのは、ここでも同じです。
□ ライフサイクルルールで、一時ファイルは自動削除する
□ アクセスされないファイルを安価なクラスへ移す(コスト。クラウドの基礎)
□ バージョニングを有効にすると、消したつもりのものが残る点に注意する
大きなファイル
□ マルチパートアップロード(分割して送り、途中から再開できる)
□ 未完了のマルチパートは課金対象。ライフサイクルで自動削除する
□ ダウンロードはストリーミングで返す(全部メモリに載せない。プロセスとメモリ)
プロフィール画像のアップロード機能で、セキュリティ上まず確認すべきことは?
実務の落とし穴まとめ
- DB にファイルを入れる — バックアップとコストで詰む
- 拡張子・Content-Type を信じる — どちらも偽装できる
- サイズ制限だけ — 画像爆弾でメモリが吹き飛ぶ
- ファイル名をキーに使う — パストラバーサル・上書き・衝突
- アプリと同じドメインで配信 — XSS が成立する
- バケットを公開設定にしてしまう — クラウド事故の定番
- 同じキーに上書き — キャッシュが古いまま残る
- 変換を同期で行う — リクエストが詰まる
- 削除の整合性を考えない — 孤児ファイルが課金され続ける
- 未完了のマルチパートを放置 — 見えないのに課金される
まとめ
- ファイルはオブジェクトストレージ、DB にはキーだけ
- 大きなファイルは署名付き URL で直接。ただし発行時に権限を確認する
- アップロードは信頼できない入力。 実バイト列で形式判定・サイズとピクセル数の制限・キーはサーバーが生成
- アプリと同じドメインで配信しない。既定は非公開
- 変換は非同期(非同期処理とメッセージング)。状態を持つ
- 上書きせず新しいキーを作る。キャッシュが古くならない
- 削除はDB を先に、実体は後追いのバッチで(バッチとジョブ)
- ライフサイクルで一時ファイルと未完了アップロードを自動削除する
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| Google Cloud Storage(日本語) | https://cloud.google.com/storage/docs?hl=ja |
| Amazon S3(日本語) | https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ |
| OWASP ファイルアップロード | https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html |
| MDN: Content-Disposition | https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Disposition |
章末問題
非公開の請求書 PDF を、利用者に安全にダウンロードさせたい。最も適切なのはどれですか。
次の章では、この変換処理のようなすぐに返さなくてよい仕事を、 どう別の流れに逃がすかを扱います。