Upgrade to Pro — share decks privately, control downloads, hide ads and more …

900アプリを支えるプラットフォームへのファイルアップロード導入から学ぶio, mime, m...

Avatar for Takuya Sakamoto Takuya Sakamoto
September 11, 2026
2.5k

900アプリを支えるプラットフォームへのファイルアップロード導入から学ぶio, mime, multipart

Avatar for Takuya Sakamoto

Takuya Sakamoto

September 11, 2026

Transcript

  1. SPEAKER 開発統括本部 UNITE開発部 UNITEサーバーグループ 坂本 拓也 / • • •

    @mohvuba Goでのバックエンド開発&機能開発の LEADが主な仕事 お笑い🎙お料理🍳おボルダリング🧗が好 き 兵庫県住みで、今回は特別出張
  2. 1. ファイルアップロード導⼊の概要 2. メモリは膨らまないか? ParseMultipartForm()と向き合う設計判断 ⽬次 INDEX 3. 拡張⼦偽装は防げるか? content

    sniffingによるMIME判定 4. アプリ特有の形式で壊れないか? HEICが教えてくれたOS依存の設計 5. まとめ
  3. 1. ファイルアップロード導⼊の概要 要件:スマホアプリからのファイルアップロード 今回つくった部分 io ‧ mime ‧ multipart multipart/form-data

    画像 スマホアプリ iOS / Android Go サーバー 受信‧検証‧保存 社内‧チームに 前例なし 保存先 S3 ストレージ
  4. 1. ファイルアップロード導⼊の概要 開発時に湧いてきた不安 • メモリは膨らまないか? ◦ ◦ 巨⼤なファイルが来たら? 不特定多数から同時に来たら? •

    拡張⼦偽装は防げるか? ◦ 実⾏ファイルを「.png」にrenameして送られたら? • アプリ特有の形式で壊れないか? ◦ iPhoneのHEICのような形式が⽇常的に来たら?
  5. 1. ファイルアップロード導⼊の概要 開発時に湧いてきた不安 • メモリは膨らまないか? ◦ ◦ 巨⼤なファイルが来たら? 不特定多数から同時に来たら? それぞれの不安について、詳しくお話ししていきます

    • 拡張⼦偽装は防げるか? ◦ 実⾏ファイルを「.png」にrenameして送られたら? • アプリ特有の形式で壊れないか? ◦ iPhoneのHEICのような形式が⽇常的に来たら?
  6. 2. ParseMultipartForm()とメモリに向かう設計判断 Goでのファイルアップロードの実装 • ParseMultipartForm() が⼿軽。多くの場合で妥当な選択肢 ◦ 実質2⾏でフォームが扱える ◦ FormFile()

    などで直感的にアクセスできる func uploadHandler(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(32 << 20); err != nil { http.Error(w, err.Error(), 400); return } file, header, err := r.FormFile("formName") // あとは file (multipart.File) を読むだけ }
  7. 2. ParseMultipartForm()とメモリに向かう設計判断 ParseMultipartForm() の仕様 func (r *Request) ParseMultipartForm(maxMemory int64) error

    リクエストの受信サイズの上限ではない • ParseMultipartForm() は、リクエスト全体を読み… ◦ ファイル部分のうち、最⼤ maxMemory バイトをメモリへ保持
  8. 2. ParseMultipartForm()とメモリに向かう設計判断 ParseMultipartForm() の仕様 func (r *Request) ParseMultipartForm(maxMemory int64) error

    リクエストの受信サイズの上限ではない • ParseMultipartForm() は、リクエスト全体を読み… ◦ ファイル部分のうち、最⼤ maxMemory バイトをメモリへ保持 ◦ 残りは⼀時ファイルとしてディスクへ保存
  9. 2. ParseMultipartForm()とメモリに向かう設計判断 ParseMultipartForm() を使う際の課題 ② ⼀時ファイルを置くディスクのサイズは実⾏環境に依存する • ⼀時ファイルは os.CreateTemp() で保存

    = 実⾏環境依存 • 読み取り専⽤FSや⼩さい/tmpだと失敗する恐れがある 実⾏インフラのストレージの制約‧仕様を実装時に考慮しなければならない
  10. 2. ParseMultipartForm()とメモリに向かう設計判断 ストリーミング処理の実現⽅法 MultipartReader + NextPart • MultipartReader() でパートを 1

    つずつ処理 • NextPart() で、「読んだ分だけメモリへ展開」を実現 • 検証もアップロードもこのReaderに接続
  11. 2. ParseMultipartForm()とメモリに向かう設計判断 サンプルコード mr,err := r.MultipartReader() // *multipart.Readerを得る if err

    != nil { return nil, err } for { part, err := mr.NextPart() // part は io.Reader if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } res, err := svc.Upload(ctx, part, cfg) }
  12. 2. ParseMultipartForm()とメモリに向かう設計判断 func ReadAndValidateSize( r io.Reader, bufSize int, maxSize int64

    ) (io.ReadSeeker, error) { var b []byte; var size int64 for { buf := make([]byte, bufSize) readByte, err := r.Read(buf) /* io.EOF なら break それ以外はerror handling */ if size += int64(readByte) /*size > maxSizeなら error */ b = append(b, buf[:readByte]...) } return bytes.NewReader(b), nil }
  13. 2. ParseMultipartForm()とメモリに向かう設計判断 func ReadAndValidateSize( r io.Reader, bufSize int, maxSize int64

    ) (io.ReadSeeker, error) { var b []byte; var size int64 for { buf := make([]byte, bufSize) readByte, err := r.Read(buf) /* io.EOF なら break それ以外はerror handling */ if size += int64(readByte) /*size > maxSizeなら error */ b = append(b, buf[:readByte]...) } return bytes.NewReader(b), nil }
  14. 2. ParseMultipartForm()とメモリに向かう設計判断 func ReadAndValidateSize( r io.Reader, bufSize int, maxSize int64

    ) (io.ReadSeeker, error) { var b []byte; var size int64 for { buf := make([]byte, bufSize) readByte, err := r.Read(buf) /* io.EOF なら break それ以外はerror handling */ if size += int64(readByte) /*size > maxSizeなら error */ b = append(b, buf[:readByte]...) } return bytes.NewReader(b), nil }
  15. 2. ParseMultipartForm()とメモリに向かう設計判断 func ReadAndValidateSize( r io.Reader, bufSize int, maxSize int64

    ) (io.ReadSeeker, error) { var b []byte; var size int64 for { buf := make([]byte, bufSize) readByte, err := r.Read(buf) /* io.EOF なら break それ以外はerror handling */ if size += int64(readByte) /*size > maxSizeなら error */ b = append(b, buf[:readByte]...) } return bytes.NewReader(b), nil }
  16. 2. ParseMultipartForm()とメモリに向かう設計判断 なぜ io.ReadSeeker にしておくのか? • 読み直しが必要なため ◦ io.Reader は読み捨て

    = 読み直しができない • なぜ読み直したい? ◦ ◦ MIME判定で、先んじて先頭512Bを読む必要があるため(→後の章で説 明) ファイルをS3に保存する際のSDKがio.ReadSeekerを求めていた
  17. 2. ParseMultipartForm()とメモリに向かう設計判断 この章のまとめ 疑ったこと • • ParseMultipartForm の「⼿軽さ」 引数の意味を確かめないまま丸投げすること 確かめたこと

    • • • 引数 maxMemory は受信サイズ上限ではない 超過分はディスクに保存される 検証は事後になる 選んだ設計 • • MultipartReader + NextPart のストリーミング処理 EOF 前のサイズ累積チェックと io.ReadSeeker への 昇格
  18. 3. content sniffingによるMIME判定 拡張⼦は「⾃⼰申告」 cute_cat.png 4D 5A 90 00 ...

    Content-Type: image/png バイナリの先頭が Windowsの実⾏ファイル
  19. 3. content sniffingによるMIME判定 拡張⼦は「⾃⼰申告」 cute_cat.png 4D 5A 90 00 ...

    Content-Type: image/png バイナリの先頭が Windowsの実⾏ファイル ファイル名もContent-Typeも疑う必要がある
  20. 3. content sniffingによるMIME判定 先頭 512B を読んで、許可リストと照合 func DetectValidMimeType( rs io.ReadSeeker,

    allowed []string )(mt string, err error){ lr := io.LimitReader(rs, 512) // 先頭だけ読む /* バイナリを読み込む */ defer f() // 読んだ分を巻き戻す処理 mt = http.DetectContentType(buf[:n]) /* allowedにmtが含まれてなければerror */ return mt, nil }
  21. 3. content sniffingによるMIME判定 先頭 512B を読んで、許可リストと照合 func DetectValidMimeType( rs io.ReadSeeker,

    allowed []string )(mt string, err error){ lr := io.LimitReader(rs, 512) // 先頭だけ読む /* バイナリを読み込む */ defer f() // 読んだ分を巻き戻す処理 mt = http.DetectContentType(buf[:n]) /* allowedにmtが含まれてなければerror */ return mt, nil }
  22. 3. content sniffingによるMIME判定 先頭 512B を読んで、許可リストと照合 func DetectValidMimeType( rs io.ReadSeeker,

    allowed []string )(mt string, err error){ lr := io.LimitReader(rs, 512) // 先頭だけ読む /* バイナリを読み込む */ defer f() // 読んだ分を巻き戻す処理 mt = http.DetectContentType(buf[:n]) /* allowedにmtが含まれてなければerror */ return mt, nil }
  23. 3. content sniffingによるMIME判定 先頭 512B を読んで、許可リストと照合 func DetectValidMimeType( rs io.ReadSeeker,

    allowed []string )(mt string, err error){ lr := io.LimitReader(rs, 512) // 先頭だけ読む /* バイナリを読み込む */ defer f() // 読んだ分を巻き戻す処理 mt = http.DetectContentType(buf[:n]) /* allowedにmtが含まれてなければerror */ return mt, nil }
  24. 3. content sniffingによるMIME判定 先頭 512B を読んで、許可リストと照合 func DetectValidMimeType( rs io.ReadSeeker,

    allowed []string )(mt string, err error){ lr := io.LimitReader(rs, 512) // 先頭だけ読む /* バイナリを読み込む */ defer f() // 読んだ分を巻き戻す処理 mt = http.DetectContentType(buf[:n]) /* allowedにmtが含まれてなければerror */ return mt, nil }
  25. 3. content sniffingによるMIME判定 読み進めた分をdeferで巻き戻しておく func DetectValidMimeType(...) (mt string, err error)

    { … f := func() { _, seekErr := rs.Seek(0, io.SeekStart) if seekErr != nil { err = multierr.Append(err, seekErr) } } /* deferによる呼び出し*/ }
  26. 3. content sniffingによるMIME判定 読み進めた分をdeferで巻き戻しておく func DetectValidMimeType(...) (mt string, err error)

    { … f := func() { _, seekErr := rs.Seek(0, io.SeekStart) if seekErr != nil { err = multierr.Append(err, seekErr) } } /* deferによる呼び出し*/ content sniffingのために512B分読み進んだ状態になっているので、 } deferでSeekして巻き戻しておく
  27. 2. ParseMultipartForm()とメモリに向かう設計判断 この章のまとめ 疑ったこと • ファイル名の拡張⼦、リクエストの Content-Typeな ど、クライアント申告の値すべて 確かめたこと •

    • 先頭 512B で中⾝は判定できること Seek という副作⽤の存在 選んだ設計 • Seek 巻き戻しの副作⽤管理まで含めた先頭 512B の content sniffing + 許可リスト照合
  28. 4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない サポートするためにぶつかった問題 ① http.DetectContentTypeでimage/heicを解決できない func DetectValidMimeType(

    rs io.ReadSeeker, allowed []string )(mt string, err error){ lr := io.LimitReader(rs, 512)「image/heic」ではなく // 先頭だけ読む /* バイナリを読み込む */ 「application/octet-stream」を返却 defer f() // 読んだ分を巻き戻す処理 mt = http.DetectContentType(buf[:n]) /* allowedにmtが含まれてなければerror */ return mt, nil }
  29. 4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない http.DetectContentTypeの実装(sniff.go) var sniffSignatures = []sniffSig{

    htmlSig("<!DOCTYPE HTML"), htmlSig("<HTML"), &exactSig{[]byte("\xFF\xD8\xFF"), "image/jpeg"}, ... // image/heicが無い } func DetectContentType(data []byte) string { for _, sig := range sniffSignatures { ... } return "application/octet-stream" // fallback }
  30. 4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない http.DetectContentTypeの実装(sniff.go) var sniffSignatures = []sniffSig{

    htmlSig("<!DOCTYPE HTML"), htmlSig("<HTML"), &exactSig{[]byte("\xFF\xD8\xFF"), "image/jpeg"}, ... // image/heicが無い } sniffSignaturesにimage/heicがなく、 func DetectContentType(data application/octet-streamでfall-back []byte) string { for _, sig := range sniffSignatures { ... } return "application/octet-stream" // fallback }
  31. 4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない http.DetectContentTypeの実装(sniff.go) var sniffSignatures = []sniffSig{

    htmlSig("<!DOCTYPE HTML"), htmlSig("<HTML"), &exactSig{[]byte("\xFF\xD8\xFF"), "image/jpeg"}, ... // image/heicが無い } 変換表をハードコード = []byte) OSのMIME DBなどは読まない func DetectContentType(data string { for _, →OSに依存せず、どの環境でも同じ結果が得られる sig := range sniffSignatures { ... } return "application/octet-stream" // fallback }
  32. 4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない 再: 先頭 512B を読んで、許可リストと照合 func

    DetectValidMimeType( rs io.ReadSeeker, allowed []string )(mt string, err error){ lr := io.LimitReader(rs, 512) // 先頭だけ読む /* バイナリを読み込む */ defer f() // 後述 // mt = http.DetectContentType(buf[:n]) mt = mimetype.Detect(buf[:n]).String() /* allowedにmtが含まれてなければerror */ return image/heciに対応しているサードパーティライブラリ mt, nil (gabriel-vasile/mimetype)を採⽤して解決 }
  33. 4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない なぜ標準ライブラリは⾮対応なのか A. WHATWG がまだ heic

    のパターンを定めていないため - golang/go #52144(2022) • WHATWG ◦ Web Hypertext Application Technology Working Group HTMLやDOMなどのWeb技術標準を策定‧標準化しているコミュニティ • http.DetectContentTypeはWHATWGのminesniffの仕様に準拠 ◦ sniff.goのコメントにその旨が記載されている • minesniffの⽬的は「ブラウザ間でファイル形式の推定⽅法を統⼀す る」こと ◦ ブラウザで表⽰しないファイル形式は対象外
  34. 4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない なぜ標準ライブラリは⾮対応なのか A. WHATWG がまだ heic

    のパターンを定めていないため - golang/go #52144(2022) • WHATWG ◦ Web Hypertext Application Technology Working Group HTMLやDOMなどのWeb技術標準を策定‧標準化しているコミュニティ HEICはブラウザで扱うファイル形式ではないので、 • http.DetectContentTypeはWHATWGのminesniffの仕様に準拠 ◦ 「⾮対応」となっていた sniff.goのコメントにその旨が記載されている • minesniffの⽬的は「ブラウザ間でファイル形式の推定⽅法を統⼀す る」こと ◦ ブラウザで表⽰しないファイル形式は対象外
  35. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない mime/type.go • Chrome、Firefoxの両⽅が採⽤する65種のみ(=必要最⼩限) var builtinTypesLower

    = map[string]string{ ".avif": "image/avif", ".css": "text/css; charset=utf-8", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", … // 全65種。.heic/.heifは存在しない } ここにないファイル形式はどうする?
  36. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない mime/type_unix.go var mimeGlobs = []string{

    "/usr/local/share/mime/globs2", "/usr/share/mime/globs2", } var typeFiles = []string{ "/etc/mime.types", "/etc/apache2/mime.types", "/etc/apache/mime.types", "/etc/httpd/conf/mime.types", } OSのMIME DBから補う
  37. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない mime/type_unix.go var mimeGlobs = []string{

    "/usr/local/share/mime/globs2", "/usr/share/mime/globs2", } var typeFiles = []string{ 2種類のMIME DB (globs2系、mime.types系) "/etc/mime.types", "/etc/apache2/mime.types", "/etc/apache/mime.types", "/etc/httpd/conf/mime.types", }
  38. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない MIME DBについて • shared-mime-info系 ◦

    ◦ shared-mime-infoが/usr/share/mimeなどに⽣成する成果物の1つ ▪ 代表的なものがglobs2。他にmagic, aliases, etc... .heic は image/heif と対応づけられている • mime.types系 ◦ ◦ ◦ /etc/mime.types など mailcap / Apache 系の複数パッケージが提供されている .heic は image/heic と対応づけられている(例外あるかも)
  39. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない MIME DBについて • shared-mime-info系 ◦

    ◦ • shared-mime-infoが/usr/share/mimeなどに⽣成する成果物の1つ ▪ 代表的なものがglobs2。他にmagic, aliases, etc... .heic は image/heif として登録されている MIME DBによって、.heicがどう登録されているかが異なっている mime.types系 また、どれが⼊っているかもOSによって異なる ◦ /etc/mime.types など ◦ mailcap / Apache 系の複数パッケージが提供されている ◦ .heic は image/heic として登録されている(基本的には)
  40. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない mime/type_unix.go func initMimeUnix() { for

    _, filename := range mimeGlobs { if err := loadMimeGlobsFile(filename); err == nil { return// Stop checking more files if mimetype database is found. } } // Fallback if no system-generated // mimetype database exists. for _, filename := range typeFiles { loadMimeFile(filename) } }
  41. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない mime/type_unix.go func initMimeUnix() { for

    _, filename := range mimeGlobs { if err := loadMimeGlobsFile(filename); err == nil { return// Stop checking more files if mimetype database is found. } } // Fallback if no system-generated // mimetype database exists. for _, filename := range typeFiles { loadMimeFile(filename) } globs2系を先に読み込み、 } ないときだけmime.types系を読み込む
  42. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない サポートするためにぶつかった問題 ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない mt =

    mimetype.Detect(buf[:n]).String() exts, err := mime.ExtensionsByType(mt) // 標準ライブラリ 1. mime/type.go の builtinTypesLower を引く → .heic はない 2. OSのMIME DBを引く a. globs2のDBがあった場合 → image/heif と判定 b. なかった場合(=mime.types系) → image/heic と判定 3. 引いた結果が image/heic でない → nil
  43. 4. HEICが教えてくれたOS依存の設計 知⾒: GoのMIMEタイプに対するスタンス • MIMEタイプは⽇々、更新され続けている • Goが⾔語機能として、全てのMIMEタイプに対応するのは現実的でない ↓ •

    Go本体は必要最⼩限のタイプのみ対応 ◦ mime.AddExtensionType という拡張ポイントも⽤意されている • 残りはOSが管理するMIME DBに委ねる OSの知識は、OSに訊く
  44. 4. HEICが教えてくれたOS依存の設計 知⾒: GoのMIMEタイプに対するスタンス② • 過去のIssueでも、⼀貫したスタイルが⾒える #22318 2017 mime: incorrect

    definition of extension by mime type audio/webm audio/webmの拡張⼦が解決できない → OSのMIME DBに定義がな い環境では解決ができないとしてclose。 #52065 2022 mime: mime.ExtensionsByType returns an unexpected extension ",v" for "text/plain" globs2依存による事象であることが指摘され、Go⾔語の問題では ないとしてclose。
  45. 4. HEICが教えてくれたOS依存の設計 同じ「HEIC を知らない」でも、設計は正反対 • net/http sniff.go → ⾃⼰解決(⾃⼰決定) ◦

    sniffSignaturesに対応表をハードコード • mime type.go, type_unix.go → OS依存(移譲) ◦ 最低限の対応表をbuiltinTypesLowerにハードコード ◦ 残りはOSのMIME DBに任せる 同じ標準ライブラリであっても、パッケージごとに設計思想が異なる
  46. 4. HEICが教えてくれたOS依存の設計 HEIC/HEIF形式サポートの実現⽅法 • 案1: コンテナに MIME DB を整備 ◦

    イメージにmime.types を焼き込む ◦ ❌ インフラ構成と暗黙に結合し、ベースイメージ変更で壊れうる • 案2: mime.AddExtensionType で起動時登録 ◦ 標準の拡張ポイントに乗れる ◦ ❌ 初期化順や配置場所などへの配慮が必要 • 案3: OS ⾮依存ライブラリで解決 ◦ gabriel-vasile/mimetypeは⾃前テーブルを持つ ◦ ✅ どの環境でも同じ結果 この案を採⽤
  47. 4. HEICが教えてくれたOS依存の設計 標準で解決できない型だけライブラリへ移譲 // 標準 mime で環境依存になる MIME-Type を明示的に列挙 var

    StdMimeUnsupportedTypes = map[string]struct{}{{“image/heic”: {}} func GetExtensionByMimeType(m string) (string, error) { if _, ok := StdMimeUnsupportedTypes[m]; ok { if mt := mimetype.Lookup(m); mt != nil { return mt.Extension(), nil } } return mime.ExtensionsByType(m) }
  48. 4. HEICが教えてくれたOS依存の設計 標準で解決できない型だけライブラリへ移譲 // 標準 mime で環境依存になる MIME-Type を明示的に列挙 var

    StdMimeUnsupportedTypes = map[string]struct{}{{“image/heic”: {}} func GetExtensionByMimeType(m string) (string, error) { if _, ok := StdMimeUnsupportedTypes[m]; ok { if mt := mimetype.Lookup(m); mt != nil { return mt.Extension(), nil } } return mime.ExtensionsByType(m) }
  49. 4. HEICが教えてくれたOS依存の設計 標準で解決できない型だけライブラリへ移譲 // 標準 mime で環境依存になる MIME-Type を明示的に列挙 var

    StdMimeUnsupportedTypes = map[string]struct{}{{“image/heic”: {}} func GetExtensionByMimeType(m string) (string, error) { if _, ok := StdMimeUnsupportedTypes[m]; ok { if mt := mimetype.Lookup(m); mt != nil { return mt.Extension(), nil } } return mime.ExtensionsByType(m) }
  50. 4. HEICが教えてくれたOS依存の設計 標準で解決できない型だけライブラリへ移譲 // 標準 mime で環境依存になる MIME-Type を明示的に列挙 var

    StdMimeUnsupportedTypes = map[string]struct{}{{“image/heic”: {}} func GetExtensionByMimeType(m string) (string, error) { if _, ok := StdMimeUnsupportedTypes[m]; ok { if mt := mimetype.Lookup(m); mt != nil { return mt.Extension(), nil } } return mime.ExtensionsByType(m) }
  51. 4. HEICが教えてくれたOS依存の設計 標準で解決できない型だけライブラリへ移譲 // 標準 mime で環境依存になる MIME-Type を明示的に列挙 var

    StdMimeUnsupportedTypes = map[string]struct{}{{“image/heic”: {}} func GetExtensionByMimeType(m string) (string, error) { if _, ok := StdMimeUnsupportedTypes[m]; ok { 実⾏環境依存をなくしつつ、サードパーティ依存も最⼩限に if mt := mimetype.Lookup(m); mt != nil { return mt.Extension(), nil } } return mime.ExtensionsByType(m) }
  52. 2. ParseMultipartForm()とメモリに向かう設計判断 この章のまとめ 疑ったこと • ExtensionsByType の答えを握る OS の MIME

    台帳 確かめたこと • type.go / type_unix.go / sniff.go を読み、「OS に委 譲(環境依存)」という 設計を理解した 選んだ設計 • 環境依存を許容せず、標準で解決できない型だけ OS ⾮依存ライブラリに委譲
  53. 5. まとめ 標準ライブラリをどこまで信じるか そのまま信じなかったこと 疑って、調べて、得たもの multipart ParseMultipartForm の 「⼿軽さ」 ParseMultipartForm

    の実装を 理解し、 ストリーミング処理を選択 mime判定 拡張⼦と Content-Type の 申告値 content sniffing と副作⽤管理 ExtensionsByType の解決結果 「OS に委ねる設計」を理解 依存範囲を明⽰して制御 HEIC/HEIF対応 地道な調査と理解が、堅牢な機能を実現する
  54. 3. content sniffingによるMIME判定 「偽装したファイル」を⽤意してテスト ✓ test_image_01.jpg image/jpeg として受理 test_image_03.HEIC image/heic

    として受理 ✗ test_image_05.docx 許可リスト外 → エラー ✓ ✗ test_image_forged_extension 拡張⼦偽装ファイル → エラー .png
  55. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない MIME問題はどうやって発覚したか? • まずは、ローカル(Mac)でテストが失敗 ◦ mime.ExtensionsByType("image/heic")

    が解決できなかった ◦ 開発PC固有の問題と判断 → darwin 限定のビルドタグで mime.AddExtensionType を登録して対 処 • 次に、カバレッジ計測をCircleCI→GitHub Actionsに移⾏したら失敗 ◦ とりあえずshared-mime-infoというMIME判定DBをapt-getしてみるも、 解決せず…
  56. 4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない MIME問題はどうやって発覚したか? • まずは、ローカル(Mac)でテストが失敗 ◦ mime.ExtensionsByType("image/heic")

    が解決できなかった ◦ 開発PC固有の問題と判断 → darwin 限定のビルドタグで mime.AddExtensionType を登録して対 処 ちゃんとmimeパッケージを読んで解決しよう、ということに • 次に、カバレッジ計測をCircleCI→GitHub Actionsに移⾏したら失敗 ◦ とりあえずshared-mime-infoというMIME判定DBをapt-getしてみるも、 解決せず…
  57. http.MaxBytesReader とは何か • r.Body を包む io.ReadCloser。累積 n バイトを超えた Read が

    *http.MaxBytesError を返す • 超過時は接続を Close 対象にし、読み残した Body をサーバ側に残さない • net/http にリクエスト Body のサーバ既定上限は無い (Server.MaxHeaderBytes はヘッダのみ) リクエスト Body の上限は「呼び出し側の仕事」 Appendix 1-1 | 2. ParseMultipartForm()と向き合う設計判断 - 補足
  58. http.MaxBytesReader の使い方 // handler の先頭で、リクエスト全体に上限をかける r.Body = http.MaxBytesReader(w, r.Body, 16<<20)

    // ParseMultipartForm も可 mr, err := r.MultipartReader() /* ... NextPart で読み進める … */ var mbe *http.MaxBytesError if errors.As(err, &mbe) { http.Error(w, "request too large", http.StatusRequestEntityTooLarge) } Appendix 1-2 | 2. ParseMultipartForm()と向き合う設計判断 - 補足
  59. http.MaxBytesReader の使い方 // handler の先頭で、リクエスト全体に上限をかける r.Body = http.MaxBytesReader(w, r.Body, 16<<20)

    // ParseMultipartForm も可 mr, err := r.MultipartReader() MultipartReader / ParseMultipartForm の /* ... NextPart で読み進める … */ どちらと組み合わせても使える var mbe *http.MaxBytesError if errors.As(err, &mbe) { http.Error(w, "request too large", http.StatusRequestEntityTooLarge) } Appendix 1-2 | 2. ParseMultipartForm()と向き合う設計判断 - 補足
  60. 「上限」は 1 つではない • リクエスト全体: MaxBytesReader(全パート + boundary + パートヘッダ)。パート

    単位ではない • パート単位: 「1ファイル最大15MB」は NextPart 側の累積チェック (ReadAndValidateSize)で実現 • multipart 組み込み: Go 1.20.3 / 1.19.8 以降、パート数(既定1000)・ヘッダ数 (既定10000)の上限(GODEBUG multipartmaxparts / multipartmaxheaders) • 本セッションの設計は多層防御: 外側 = MaxBytesReader、内側 = ReadAndValidateSize Appendix 1-3 | 2. ParseMultipartForm()と向き合う設計判断 - 補足
  61. 「上限」は 1 つではない • リクエスト全体: MaxBytesReader(全パート + boundary + パートヘッダ)。パート

    単位ではない • パート単位: 「1ファイル最大15MB」は NextPart 側の累積チェック (ReadAndValidateSize)で実現 • ParseMultipartForm multipart 組み込み: Go 1.20.3 / 1.19.8 以降、パート数(既定1000)・ヘッダ数 の maxMemory はこのどれでもない (既定10000)の上限(GODEBUG multipartmaxparts / multipartmaxheaders) • 本セッションの設計は多層防御: 外側 = MaxBytesReader、内側 = ReadAndValidateSize Appendix 1-3 | 2. ParseMultipartForm()と向き合う設計判断 - 補足
  62. 「拡張子 ⇔ MIME」の対応表はどこから来たか 1992 1993 1990s 2003 MIME 誕生 mailcap

    / mime.types Web サーバ・ OS が借用 Shared MIME-info spec RFC 1341(現 2045-2049)。 メールの Content-Type のた めの規格。拡張子との対応は 規格の範囲外 RFC 1524。「型 → 開くプログ ラム」はローカル設定。対にな る mime.types(拡張子 → 型)もメーラー metamail の 設定ファイルとして誕生 NCSA httpd / Apache が mime.types 形式を採用。 Debian mime-support(現 media-types)が /etc/mime.types をシステム 共通化。Windows はレジスト リ freedesktop.org。GNOME / KDE / ROX の DB を統一 → /usr/share/mime/globs2 Appendix 2-1 | 4. HEICが教えてくれたOS依存の設計 - 補足
  63. 「拡張子 ⇔ MIME」の対応表はどこから来たか 1992 1993 1990s 2003 MIME 誕生 mailcap

    / mime.types Web サーバ・ OS が借用 Shared MIME-info spec RFC 1341(現 2045-2049)。 メールの Content-Type のた めの規格。拡張子との対応は 規格の範囲外 RFC 1524。「型 → 開くプログ ラム」はローカル設定。対にな る mime.types(拡張子 → 型)もメーラー metamail の 設定ファイルとして誕生 NCSA httpd / Apache が mime.types 形式を採用。 Debian mime-support(現 media-types)が /etc/mime.types をシステム 共通化。Windows はレジスト リ freedesktop.org。GNOME / KDE / ROX の DB を統一 → /usr/share/mime/globs2 対応表は「規格」ではなく「メーラーの設定ファイル」として生ま れ、OS に格上げされた Appendix 2-1 | 4. HEICが教えてくれたOS依存の設計 - 補足
  64. なぜ OS が持っているのか • 「.foo が何か」はフォーマット規格が決めるのではなく、そのマシンに何が入ってい るかの反映(慣習) • 新しい型は「それを扱うソフトを入れる」ことで増える →

    登録先はソフトの登録先 = OS(パッケージ / レジストリ) • Shared MIME-info spec の目的: 同一マシン上のアプリが型について合意する / アプリは一箇所に登録するだけでよい OS の MIME DB が解いているのは「同一マシン上のアプリ間の合意」 Appendix 2-2 | 4. HEICが教えてくれたOS依存の設計 - 補足
  65. Go の意思決定との関係 • Go の mime パッケージ(2010 年新設)は、この OS の

    DB に乗る設計。「なぜ委 ねるか」を書いた設計文書は無い • 根拠は type.go のコメント(built-in table is small…)と Issue(#22318, #46578)に散在 = 「stdlib で大きな表を維持しない」というスコープ判断 • 同じ stdlib でも sniff.go は WHATWG 準拠の自前実装。合意相手が違う(OS の DB = 同一マシンのアプリ間、sniff.go = ブラウザ間) 我々が合意すべき相手は「クライアント」と「自分たちの過去の出力」。 DB はそこに寄与していない 出典: RFC 1524 / specifications.freedesktop.org/shared-mime-info / golang/go #22318, #46578 / salsa.debian.org/debian/media-types Appendix 2-3 | 4. HEICが教えてくれたOS依存の設計 - 補足 OS の
  66. 参考⽂献 • • • Go Docs ◦ https://pkg.go.dev/net/http ▪ https://pkg.go.dev/net/http#Request.ParseMultipartForm

    ▪ https://pkg.go.dev/net/http#DetectContentType ◦ https://pkg.go.dev/mime ▪ https://pkg.go.dev/mime#AddExtensionType ▪ https://pkg.go.dev/mime#ExtensionsByType ◦ https://pkg.go.dev/mime/multipart ▪ https://pkg.go.dev/mime/multipart#NewReader ▪ https://pkg.go.dev/mime/multipart#Reader.NextPart golang/go issues ◦ https://github.com/golang/go/issues/22318 ◦ https://github.com/golang/go/issues/52065 ◦ https://github.com/golang/go/issues/52144 MIME DB ◦ https://www.freedesktop.org/wiki/Specifications/shared-mime-info-spec/ ◦ https://salsa.debian.org/debian/media-types/-/raw/master/mime.types