# Echo ファイルアップロード本番設計：multipart・S3 ストリーミング・presigned URL・検証で安全に受ける

> Go Echo（v5）でファイルアップロードを本番品質に実装するガイド。c.FormFile / c.MultipartForm の使い方、メモリに載せない S3 ストリーミングアップロード（AWS SDK Go v2）、巨大ファイルを API に通さない presigned URL 方式、サイズ上限・MIME 検証・ファイル名のパストラバーサル対策・コンテンツ重複排除までを実コードで解説します。

- 公開日: 2026-06-28
- 著者: 友田 陽大
- タグ: Go, Echo, セキュリティ, AWS, アーキテクチャ設計, コスト最適化
- URL: https://tomodahinata.com/blog/go-echo-file-upload-multipart-s3-streaming-presigned-url-guide
- カテゴリ: Go・Echo 本番運用
- 総合ガイド: https://tomodahinata.com/blog/go-echo-framework-production-guide

## 要点

- アップロードはメモリに全部載せず io.Copy でストリーミングする。c.FormFile→file.Open()→io.Copy で、巨大ファイルでもメモリを食わない
- 巨大ファイルは API を経由させず presigned URL でクライアント→S3 直アップロードにする。サーバーの帯域・メモリ・コストを劇的に削減できる
- file.Filename は信用しない。そのままパスに使うとパストラバーサル。UUID 等で命名し、拡張子と MIME は許可リストで検証する
- サイズ上限は BodyLimit と S3 側の両方で。MIME は拡張子ではなく先頭512バイトの http.DetectContentType で実体を見る
- S3 への保存は AWS SDK Go v2 の manager.Uploader でマルチパート並列。冪等性はコンテンツハッシュをキーにして重複排除する

---

ファイルアップロードは「動かす」のは簡単ですが、**本番で安全に受ける**のは奥が深い領域です。巨大ファイルでメモリが溢れる、悪意あるファイル名でサーバーのファイルを上書きされる、拡張子を偽装した実行ファイルを掴まされる、アップロード帯域がサーバーを詰まらせる——どれも「とりあえず受ける」コードでは防げません。

この記事は、[Go Echo 本番運用ガイド](/blog/go-echo-framework-production-guide)のファイルアップロード編です。Echo v5 の `multipart` API を起点に、**メモリに載せないストリーミング**、**巨大ファイルを API に通さない presigned URL 方式**、そして**セキュリティ検証**までを、本番で使える形で実装します。

> **この記事のルール**：Echo の API は **公式ドキュメント（v5・2026年6月時点）** に基づきます。AWS SDK for Go v2 は更新されるため公式で最新を確認してください。**認証情報は IAM ロール／環境変数前提**で、アクセスキーをコードに書きません。

---

## 1. 基本：`c.FormFile` で1ファイルを受ける（ストリーミング）

Echo v5 のファイル受け取りは `c.FormFile("field")` → `*multipart.FileHeader` です。ポイントは、**ファイル全体をメモリに読み込まず、`io.Copy` でストリーミング**することです。

```go
func (h *UploadHandler) Upload(c *echo.Context) error {
	file, err := c.FormFile("file") // *multipart.FileHeader
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "file is required")
	}

	src, err := file.Open()
	if err != nil {
		return err
	}
	defer src.Close()

	dst, err := os.Create(safeName(file.Filename)) // ← ファイル名は必ずサニタイズ（第4章）
	if err != nil {
		return err
	}
	defer dst.Close()

	if _, err = io.Copy(dst, src); err != nil { // ストリーミング：メモリに全部載せない
		return err
	}
	return c.JSON(http.StatusCreated, map[string]string{"status": "uploaded"})
}
```

複数ファイルは `c.MultipartForm()` から取り出します。

```go
form, err := c.MultipartForm()
if err != nil {
	return echo.NewHTTPError(http.StatusBadRequest, "invalid multipart form")
}
for _, file := range form.File["files"] { // <input name="files" multiple>
	src, _ := file.Open()
	// ... 同様に io.Copy でストリーミング保存
	src.Close()
}
```

> **なぜストリーミングが重要か**：`io.ReadAll` で全バイトをメモリに載せると、100MB のファイル × 同時 50 リクエストで 5GB を消費し、OOM でプロセスが落ちます。`io.Copy` は固定サイズのバッファで少しずつ転送するので、**ファイルサイズに関係なくメモリ使用量が一定**です。

---

## 2. サイズ上限：BodyLimit で「読む前」に弾く

巨大リクエストは、**ボディを読み始める前**にサイズで拒否します。[ミドルウェアガイド](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide)の `BodyLimit` をアップロード経路に効かせます。

```go
// アップロード用グループにだけ大きめの上限を設定（v5 は整数バイト）
uploads := e.Group("/uploads")
uploads.Use(middleware.BodyLimit(25_000_000)) // 25MB を超えたら 413
```

`BodyLimit` は**多層防御の最外層**です。これに加えて、ストレージ側（S3 のオブジェクトサイズ）やビジネスルール（プラン別上限）でも検証します。「1箇所で守る」ではなく「各層で守る」のが本番の作法です。

---

## 3. S3 へストリーミング保存：AWS SDK Go v2

本番では、アップロードをサーバーのローカルディスクではなく**オブジェクトストレージ（S3 等）**に保存します。AWS SDK for Go v2 の `manager.Uploader` は、**マルチパートで並列アップロード**しつつ `io.Reader` を受けるので、`file.Open()` の結果を**そのままストリーミング**できます。

```go
import (
	"github.com/aws/aws-sdk-go-v2/feature/s3/manager"
	"github.com/aws/aws-sdk-go-v2/service/s3"
)

type S3Storage struct {
	uploader *manager.Uploader
	bucket   string
}

func NewS3Storage(client *s3.Client, bucket string) *S3Storage {
	return &S3Storage{uploader: manager.NewUploader(client), bucket: bucket}
}

func (s *S3Storage) Save(ctx context.Context, key, contentType string, body io.Reader) error {
	_, err := s.uploader.Upload(ctx, &s3.PutObjectInput{
		Bucket:      &s.bucket,
		Key:         &key,
		Body:        body,         // io.Reader をそのまま渡す＝メモリに載せない
		ContentType: &contentType,
	})
	return err
}
```

ハンドラからは、`c.Request().Context()` を渡して[キャンセル・タイムアウトを伝播](/blog/go-echo-database-postgresql-pgx-sqlc-gorm-transaction-guide#1-最重要contextをハンドラからdbまで貫通させる)させます。

```go
func (h *UploadHandler) UploadToS3(c *echo.Context) error {
	file, err := c.FormFile("file")
	if err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "file is required")
	}
	src, err := file.Open()
	if err != nil {
		return err
	}
	defer src.Close()

	contentType, err := sniffContentType(src) // 第4章：実体から MIME を判定
	if err != nil || !allowedMIME[contentType] {
		return echo.NewHTTPError(http.StatusUnsupportedMediaType, "unsupported file type")
	}

	key := "uploads/" + uuid.NewString() + extByMIME(contentType) // ファイル名は自前で決める
	if err := h.storage.Save(c.Request().Context(), key, contentType, src); err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, map[string]string{"key": key})
}
```

> **認証情報を書かない**：`s3.Client` は SDK の既定資格情報チェーン（ECS タスクロール／EC2 インスタンスロール／環境変数）から構成します。アクセスキーをコードや環境変数に直書きせず、**IAM ロール**で最小権限（対象バケットの `PutObject` だけ）を付与するのが正解です（[デプロイのシークレット管理](/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide#7-シークレット管理とイメージ衛生)）。

---

## 4. セキュリティ：3つの必須検証

アップロードは**外部入力の塊**です。最低限この3点を守ります。

### 4.1 ファイル名を信用しない（パストラバーサル対策）

`file.Filename` は**クライアントが自由に決めた文字列**です。`../../etc/passwd` や `../app.go` のような値を**そのままパスに使うと、サーバーの任意ファイルを上書き**できてしまいます。**ファイル名はサーバーが決めます**。

```go
// ❌ 危険：クライアント由来の名前をそのままパスに
dst, _ := os.Create("/data/" + file.Filename) // ../ でディレクトリを抜けられる

// ✅ 安全：自前で命名（UUID）。元の名前はメタデータとして「値」で保持
key := uuid.NewString() + filepath.Ext(file.Filename) // 拡張子だけは検証後に流用
// 表示用の元ファイル名は DB のカラムに保存し、パスには使わない
```

### 4.2 MIME は拡張子ではなく「実体」で見る

`.png` という拡張子は嘘をつけます。**ファイルの先頭512バイト**を `http.DetectContentType` に渡し、実体の Content-Type を判定して許可リストで照合します。

```go
var allowedMIME = map[string]bool{
	"image/jpeg": true, "image/png": true, "application/pdf": true,
}

func sniffContentType(rs io.ReadSeeker) (string, error) {
	buf := make([]byte, 512)
	n, err := rs.Read(buf)
	if err != nil && err != io.EOF {
		return "", err
	}
	if _, err := rs.Seek(0, io.SeekStart); err != nil { // 読んだぶんを巻き戻す
		return "", err
	}
	return http.DetectContentType(buf[:n]), nil
}
```

`multipart.File` は `io.ReadSeeker` を満たすので、**先頭を読んで判定 → `Seek(0)` で巻き戻して保存**できます。

### 4.3 その他の防御

| リスク | 対策 |
| --- | --- |
| 実行可能ファイルの混入 | MIME 許可リスト + 拡張子の再付与（クライアント拡張子を使わない） |
| マルウェア | アップロード後に非同期スキャン（[S3 のマルウェアスキャン](/blog/aws-guardduty-malware-protection-s3-standalone-scanning-guide)等）し、検疫が済むまで非公開 |
| 画像爆弾（decompression bomb） | 画像はサイズ・寸法を検証してから再エンコード |
| 過剰アップロード | [RateLimiter](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide#9-ratelimiter重い処理の前で過剰アクセスを遮断) をアップロード経路に |

---

## 5. presigned URL：巨大ファイルは API を経由させない

ここが**本番の最重要パターン**です。動画や大きな画像を**API サーバー経由**でアップロードすると、サーバーの帯域・メモリ・実行時間（と課金）を消費します。代わりに、**サーバーは「アップロード許可証（presigned URL）」を発行するだけ**にし、**クライアントが直接 S3 へアップロード**します。

```go
import "github.com/aws/aws-sdk-go-v2/service/s3"

// サーバー：アップロード先と短命の署名付き URL を発行する（ファイル本体は通さない）
func (h *UploadHandler) CreateUploadURL(c *echo.Context) error {
	claims, _ := CurrentUser(c) // 認証必須（誰のアップロードか）
	key := "uploads/" + claims.UserID + "/" + uuid.NewString()

	ps := s3.NewPresignClient(h.s3)
	req, err := ps.PresignPutObject(c.Request().Context(), &s3.PutObjectInput{
		Bucket: &h.bucket,
		Key:    &key,
	}, s3.WithPresignExpires(5*time.Minute)) // 短命：5分で失効
	if err != nil {
		return err
	}
	return c.JSON(http.StatusOK, map[string]string{"url": req.URL, "key": key})
}
```

クライアントは返ってきた `url` に対して **`PUT` で直接 S3 にアップロード**します。アップロード完了後、`key` をサーバーに通知して DB に紐付けます。

| 方式 | サーバー経由 | presigned URL 直アップロード |
| --- | --- | --- |
| サーバー帯域 | 全データが通る | **通らない（許可証だけ）** |
| メモリ/実行時間 | ファイルサイズに比例 | **一定（極小）** |
| 大きいファイル | API タイムアウトの懸念 | **S3 が直接受ける** |
| コスト | サーバーの転送・実行課金 | **大幅減** |
| 向く場面 | 小さいファイル・即時加工 | **動画・大画像・大量アップロード** |

> **判断の目安**：数 MB 以下で即座に検証・加工が要るなら**サーバー経由**、それ以上や大量なら**presigned URL**。presigned URL でも、アップロード完了を S3 イベント（[S3 → Lambda の非同期処理](/blog/aws-sqs-lambda-eventbridge-idempotent-async-processing-guide)）で受けて検証・スキャン・サムネ生成につなげます。サーバーレスでの重い後処理の逃がし方は、私が[決済・帳票基盤](/case-studies/restaurant-matching)でも採った「API は薄く、重い処理はイベント駆動へ」の定石です。

---

## 6. 冪等性：同じファイルを二重に保存しない

リトライや二重送信で**同じファイルが複数回保存される**のを防ぐには、**コンテンツのハッシュをキー**にします。「内容が同じなら同じキー」にすれば、再アップロードは同じオブジェクトを上書きするだけになり、重複が生まれません。

```go
// 内容ベースのキー（content-addressed storage）
func contentKey(prefix string, sum [32]byte, ext string) string {
	return prefix + "/" + hex.EncodeToString(sum[:]) + ext // 同一内容 → 同一キー
}
```

ハッシュは保存ストリームと並行して計算できます（`io.TeeReader` で「保存しながらハッシュ」）。この「冪等性を構造で担保する」発想は、[決済の二重課金対策](/blog/payment-double-charge-prevention-idempotency-procurement-guide)と同じ原則です。

---

## まとめ：ファイルアップロードを本番品質にする7原則

1. **`io.Copy` でストリーミング**。`io.ReadAll` でメモリに載せない。
2. **`BodyLimit` で読む前にサイズで弾く**（多層防御の最外層）。
3. **S3 へは `manager.Uploader` で `io.Reader` を直結**。認証情報は IAM ロール。
4. **`file.Filename` を信用しない**。サーバーが UUID で命名（パストラバーサル対策）。
5. **MIME は `http.DetectContentType` で実体判定**し許可リストで照合。
6. **巨大ファイルは presigned URL でクライアント→S3 直アップロード**（帯域・メモリ・コスト削減）。
7. **冪等性はコンテンツハッシュ**をキーに、後処理はイベント駆動へ逃がす。

ファイルアップロードは、Echo の薄い `multipart` API と、ストレージ・セキュリティ・コストの設計が噛み合って初めて本番品質になります。リアルタイムな進捗通知が要るなら[WebSocket/SSE](/blog/go-echo-websocket-sse-realtime-streaming-guide)、全体像は[本番運用ガイド](/blog/go-echo-framework-production-guide)へ。
