Slide 1

Slide 1 text

標準ライブラリをどこまで信じるか 900アプリを⽀えるプラットフォームへの ファイルアップロード導⼊から学ぶ io‧mime‧multipart 坂本 拓也 / Takuya Sakamoto

Slide 2

Slide 2 text

SPEAKER 開発統括本部 UNITE開発部 UNITEサーバーグループ 坂本 拓也 / ● ● ● @mohvuba Goでのバックエンド開発&機能開発の LEADが主な仕事 お笑い🎙お料理🍳おボルダリング🧗が好 き 兵庫県住みで、今回は特別出張

Slide 3

Slide 3 text

ヤプリの製品 ノーコードのアプリ開発プラットフォーム 「Yappli」 ● 750社以上で導⼊、 900アプリ以上を提供 ● 50種類以上の機能で、 多様なシーンに合致した アプリが作成可能

Slide 4

Slide 4 text

Goの標準ライブラリは便利💡

Slide 5

Slide 5 text

でも… 便利さを過信すると、 思わぬ不具合を⽣んでしまうかも

Slide 6

Slide 6 text

開発プロダクトの インシデントに発展してしまうかも😱

Slide 7

Slide 7 text

ヤプリの場合、 750社‧900+アプリに影響が出るかも 🔥

Slide 8

Slide 8 text

では、 標準ライブラリをどこまで信じるか

Slide 9

Slide 9 text

事例 900アプリを⽀えるプラットフォームへの ファイルアップロード導⼊ (io, mime, multipart)

Slide 10

Slide 10 text

導⼊にあたって、標準ライブラリを過信しないために、 何を疑い、 何を確かめ、 どんな設計を選んだのか?

Slide 11

Slide 11 text

本セッションが、皆さんの 標準ライブラリへの懐疑⼼(探究⼼) ‧ より堅牢な実装への⼀歩 となれば幸いです󰢛

Slide 12

Slide 12 text

1. ファイルアップロード導⼊の概要 2. メモリは膨らまないか? ParseMultipartForm()と向き合う設計判断 ⽬次 INDEX 3. 拡張⼦偽装は防げるか? content sniffingによるMIME判定 4. アプリ特有の形式で壊れないか? HEICが教えてくれたOS依存の設計 5. まとめ

Slide 13

Slide 13 text

ファイルアップロード導⼊の 概要

Slide 14

Slide 14 text

1. ファイルアップロード導⼊の概要 要件:スマホアプリからのファイルアップロード 今回つくった部分 io ‧ mime ‧ multipart multipart/form-data 画像 スマホアプリ iOS / Android Go サーバー 受信‧検証‧保存 社内‧チームに 前例なし 保存先 S3 ストレージ

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

メモリは膨らまないか? ParseMultipartForm()と向き合う設計 判断

Slide 18

Slide 18 text

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) を読むだけ }

Slide 19

Slide 19 text

2. ParseMultipartForm()とメモリに向かう設計判断 ParseMultipartForm() の仕様 func (r *Request) ParseMultipartForm(maxMemory int64) error

Slide 20

Slide 20 text

2. ParseMultipartForm()とメモリに向かう設計判断 ParseMultipartForm() の仕様 func (r *Request) ParseMultipartForm(maxMemory int64) error リクエストの受信サイズの上限

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

2. ParseMultipartForm()とメモリに向かう設計判断 ParseMultipartForm() を使う際の課題 ① リクエストの受信が完了するまで、handlerの処理が⽌まってしまう ② ⼀時ファイルを置くディスクのサイズは実⾏環境に依存する

Slide 24

Slide 24 text

2. ParseMultipartForm()とメモリに向かう設計判断 ParseMultipartForm() を使う際の課題 ① リクエストの受信が完了するまで、handlerの処理が⽌まってしまう ● 巨⼤リクエストも受信が完了するまで弾けない ● 最終的に弾くファイルであっても、ディスクを消費する ● ファイル数×同時リクエスト数次第で、メモリやディスクがスパイク する サーバリソースの逼迫、処理の遅延、セキュリティ対策の隙間不⾜、etc…

Slide 25

Slide 25 text

2. ParseMultipartForm()とメモリに向かう設計判断 ParseMultipartForm() を使う際の課題 ② ⼀時ファイルを置くディスクのサイズは実⾏環境に依存する ● ⼀時ファイルは os.CreateTemp() で保存 = 実⾏環境依存 ● 読み取り専⽤FSや⼩さい/tmpだと失敗する恐れがある 実⾏インフラのストレージの制約‧仕様を実装時に考慮しなければならない

Slide 26

Slide 26 text

2. ParseMultipartForm()とメモリに向かう設計判断 今回実装する機能の要件 最⼤ 15MB N ユーザー 900+ アプリ

Slide 27

Slide 27 text

2. ParseMultipartForm()とメモリに向かう設計判断 今回実装する機能の要件 最⼤ 15MB N ユーザー 900+ アプリ 「受信が完了してから検証」は許容しにくい

Slide 28

Slide 28 text

2. ParseMultipartForm()とメモリに向かう設計判断 今回実装する機能の要件 最⼤ 15MB N ユーザー 900+ アプリ 「少しずつ読みながら検証する(ストリーミング処理)」ことに

Slide 29

Slide 29 text

2. ParseMultipartForm()とメモリに向かう設計判断 ストリーミング処理の実現⽅法 MultipartReader + NextPart ● MultipartReader() でパートを 1 つずつ処理 ● NextPart() で、「読んだ分だけメモリへ展開」を実現 ● 検証もアップロードもこのReaderに接続

Slide 30

Slide 30 text

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) }

Slide 31

Slide 31 text

2. ParseMultipartForm()とメモリに向かう設計判断 受信サイズの検証⽅法 ● NextPart() で取得したパートごとにサイズを検証 ● 検証のタイミングで io.Reader → io.ReaderSeeker へ変換 ついでに「io.Reader → io.ReaderSeeker への変換」も

Slide 32

Slide 32 text

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 }

Slide 33

Slide 33 text

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 }

Slide 34

Slide 34 text

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 }

Slide 35

Slide 35 text

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 }

Slide 36

Slide 36 text

2. ParseMultipartForm()とメモリに向かう設計判断 なぜ io.ReadSeeker にしておくのか? ● 読み直しが必要なため ○ io.Reader は読み捨て = 読み直しができない ● なぜ読み直したい? ○ ○ MIME判定で、先んじて先頭512Bを読む必要があるため(→後の章で説 明) ファイルをS3に保存する際のSDKがio.ReadSeekerを求めていた

Slide 37

Slide 37 text

2. ParseMultipartForm()とメモリに向かう設計判断 この章のまとめ 疑ったこと ● ● ParseMultipartForm の「⼿軽さ」 引数の意味を確かめないまま丸投げすること 確かめたこと ● ● ● 引数 maxMemory は受信サイズ上限ではない 超過分はディスクに保存される 検証は事後になる 選んだ設計 ● ● MultipartReader + NextPart のストリーミング処理 EOF 前のサイズ累積チェックと io.ReadSeeker への 昇格

Slide 38

Slide 38 text

拡張⼦偽装は防げるか? content sniffingによるMIME判定

Slide 39

Slide 39 text

3. content sniffingによるMIME判定 拡張⼦は「⾃⼰申告」 cute_cat.png Content-Type: image/png

Slide 40

Slide 40 text

3. content sniffingによるMIME判定 拡張⼦は「⾃⼰申告」 cute_cat.png クライアント側で ⾃由に設定できる値 Content-Type: image/png

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

3. content sniffingによるMIME判定 content sniffingで防ぐ ● ● 多くのファイル形式は、先頭バイト列に「⾃分が何者か」を⽰す情報がある ファイルの先頭バイト列を読み取り、ファイル形式を判定する PNG 89 50 4E 47 0D 0A 1A 0A JPEG FF D8 FF PDF 25 50 44 46 %PDF HEIC (offset 4) 66 74 79 70 68 65 69 63 ftypheic \x89PNG

Slide 44

Slide 44 text

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 }

Slide 45

Slide 45 text

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 }

Slide 46

Slide 46 text

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 }

Slide 47

Slide 47 text

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 }

Slide 48

Slide 48 text

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 }

Slide 49

Slide 49 text

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による呼び出し*/ }

Slide 50

Slide 50 text

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して巻き戻しておく

Slide 51

Slide 51 text

2. ParseMultipartForm()とメモリに向かう設計判断 この章のまとめ 疑ったこと ● ファイル名の拡張⼦、リクエストの Content-Typeな ど、クライアント申告の値すべて 確かめたこと ● ● 先頭 512B で中⾝は判定できること Seek という副作⽤の存在 選んだ設計 ● Seek 巻き戻しの副作⽤管理まで含めた先頭 512B の content sniffing + 許可リスト照合

Slide 52

Slide 52 text

アプリ特有の形式で 壊れないか? HEICが教えてくれたOS依存の設計

Slide 53

Slide 53 text

4. HEICが教えてくれたOS依存の設計 HEIC/HEIF形式のサポート ● ファイルアップロード機能では、画像ファイルもサポートする仕様 ● iPhone (iOS11+) の場合、写真はHEIF( .heic )で保存される ● Yappli製アプリには、多数のiPhoneユーザーが存在 HEIC/HEIF形式のサポートは必須

Slide 54

Slide 54 text

4. HEICが教えてくれたOS依存の設計 サポートするためにぶつかった問題 ① http.DetectContentTypeでimage/heicを解決できない ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない

Slide 55

Slide 55 text

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 }

Slide 56

Slide 56 text

4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない http.DetectContentTypeの実装(sniff.go) var sniffSignatures = []sniffSig{ htmlSig("

Slide 57

Slide 57 text

4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない http.DetectContentTypeの実装(sniff.go) var sniffSignatures = []sniffSig{ htmlSig("

Slide 58

Slide 58 text

4. HEICが教えてくれたOS依存の設計 - ① http.DetectContentTypeでimage/heicを解決できない http.DetectContentTypeの実装(sniff.go) var sniffSignatures = []sniffSig{ htmlSig("

Slide 59

Slide 59 text

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)を採⽤して解決 }

Slide 60

Slide 60 text

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の⽬的は「ブラウザ間でファイル形式の推定⽅法を統⼀す る」こと ○ ブラウザで表⽰しないファイル形式は対象外

Slide 61

Slide 61 text

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の⽬的は「ブラウザ間でファイル形式の推定⽅法を統⼀す る」こと ○ ブラウザで表⽰しないファイル形式は対象外

Slide 62

Slide 62 text

4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない サポートするためにぶつかった問題 ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない mt = mimetype.Detect(buf[:n]).String() exts, err := mime.ExtensionsByType(mt) // 標準ライブラリ

Slide 63

Slide 63 text

4. HEICが教えてくれたOS依存の設計 - ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない サポートするためにぶつかった問題 ② mime.ExtensionsByTypeでimage/heic⇔.heicを解決できない mt = mimetype.Detect(buf[:n]).String() exts, err := mime.ExtensionsByType(mt) // 標準ライブラリ nilが返ってきてしまう… →なぜ?

Slide 64

Slide 64 text

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は存在しない } ここにないファイル形式はどうする?

Slide 65

Slide 65 text

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から補う

Slide 66

Slide 66 text

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", }

Slide 67

Slide 67 text

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 と対応づけられている(例外あるかも)

Slide 68

Slide 68 text

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 として登録されている(基本的には)

Slide 69

Slide 69 text

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) } }

Slide 70

Slide 70 text

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系を読み込む

Slide 71

Slide 71 text

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

Slide 72

Slide 72 text

4. HEICが教えてくれたOS依存の設計 知⾒: GoのMIMEタイプに対するスタンス ● MIMEタイプは⽇々、更新され続けている ● Goが⾔語機能として、全てのMIMEタイプに対応するのは現実的でない ↓ ● Go本体は必要最⼩限のタイプのみ対応 ○ mime.AddExtensionType という拡張ポイントも⽤意されている ● 残りはOSが管理するMIME DBに委ねる OSの知識は、OSに訊く

Slide 73

Slide 73 text

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。

Slide 74

Slide 74 text

4. HEICが教えてくれたOS依存の設計 同じ「HEIC を知らない」でも、設計は正反対 ● net/http sniff.go → ⾃⼰解決(⾃⼰決定) ○ sniffSignaturesに対応表をハードコード ● mime type.go, type_unix.go → OS依存(移譲) ○ 最低限の対応表をbuiltinTypesLowerにハードコード ○ 残りはOSのMIME DBに任せる 同じ標準ライブラリであっても、パッケージごとに設計思想が異なる

Slide 75

Slide 75 text

4. HEICが教えてくれたOS依存の設計 HEIC/HEIF形式サポートの実現⽅法 ● 案1: コンテナに MIME DB を整備 ○ イメージにmime.types を焼き込む ○ ❌ インフラ構成と暗黙に結合し、ベースイメージ変更で壊れうる ● 案2: mime.AddExtensionType で起動時登録 ○ 標準の拡張ポイントに乗れる ○ ❌ 初期化順や配置場所などへの配慮が必要 ● 案3: OS ⾮依存ライブラリで解決 ○ gabriel-vasile/mimetypeは⾃前テーブルを持つ ○ ✅ どの環境でも同じ結果 この案を採⽤

Slide 76

Slide 76 text

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) }

Slide 77

Slide 77 text

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) }

Slide 78

Slide 78 text

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) }

Slide 79

Slide 79 text

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) }

Slide 80

Slide 80 text

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) }

Slide 81

Slide 81 text

2. ParseMultipartForm()とメモリに向かう設計判断 この章のまとめ 疑ったこと ● ExtensionsByType の答えを握る OS の MIME 台帳 確かめたこと ● type.go / type_unix.go / sniff.go を読み、「OS に委 譲(環境依存)」という 設計を理解した 選んだ設計 ● 環境依存を許容せず、標準で解決できない型だけ OS ⾮依存ライブラリに委譲

Slide 82

Slide 82 text

まとめ

Slide 83

Slide 83 text

5. まとめ 標準ライブラリをどこまで信じるか そのまま信じなかったこと 疑って、調べて、得たもの multipart ParseMultipartForm の 「⼿軽さ」 ParseMultipartForm の実装を 理解し、 ストリーミング処理を選択 mime判定 拡張⼦と Content-Type の 申告値 content sniffing と副作⽤管理 ExtensionsByType の解決結果 「OS に委ねる設計」を理解 依存範囲を明⽰して制御 HEIC/HEIF対応 地道な調査と理解が、堅牢な機能を実現する

Slide 84

Slide 84 text

便利な標準ライブラリをそのまま信じず、 その挙動を⾃分の⼿で確かめてみる

Slide 85

Slide 85 text

その姿勢‧探究⼼が、 みなさんの実装を⼀歩堅牢に‧⾼品質に✨

Slide 86

Slide 86 text

ご清聴ありがとうございました!!

Slide 87

Slide 87 text

Appendix

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

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してみるも、 解決せず…

Slide 90

Slide 90 text

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してみるも、 解決せず…

Slide 91

Slide 91 text

http.MaxBytesReader とは何か • r.Body を包む io.ReadCloser。累積 n バイトを超えた Read が *http.MaxBytesError を返す • 超過時は接続を Close 対象にし、読み残した Body をサーバ側に残さない • net/http にリクエスト Body のサーバ既定上限は無い (Server.MaxHeaderBytes はヘッダのみ) リクエスト Body の上限は「呼び出し側の仕事」 Appendix 1-1 | 2. ParseMultipartForm()と向き合う設計判断 - 補足

Slide 92

Slide 92 text

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()と向き合う設計判断 - 補足

Slide 93

Slide 93 text

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()と向き合う設計判断 - 補足

Slide 94

Slide 94 text

「上限」は 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()と向き合う設計判断 - 補足

Slide 95

Slide 95 text

「上限」は 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()と向き合う設計判断 - 補足

Slide 96

Slide 96 text

「拡張子 ⇔ 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依存の設計 - 補足

Slide 97

Slide 97 text

「拡張子 ⇔ 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依存の設計 - 補足

Slide 98

Slide 98 text

なぜ OS が持っているのか • 「.foo が何か」はフォーマット規格が決めるのではなく、そのマシンに何が入ってい るかの反映(慣習) • 新しい型は「それを扱うソフトを入れる」ことで増える → 登録先はソフトの登録先 = OS(パッケージ / レジストリ) • Shared MIME-info spec の目的: 同一マシン上のアプリが型について合意する / アプリは一箇所に登録するだけでよい OS の MIME DB が解いているのは「同一マシン上のアプリ間の合意」 Appendix 2-2 | 4. HEICが教えてくれたOS依存の設計 - 補足

Slide 99

Slide 99 text

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 の

Slide 100

Slide 100 text

参考⽂献 ● ● ● 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