[インデックス 13065] ファイルの概要
このコミットは、Go言語の標準ライブラリ mime/multipart
パッケージにおける、マルチパートメッセージの解析ロジックの修正に関するものです。具体的には、空のパート(ボディを持たないパート)の扱いを改善し、特に次のパートの境界線の前にCRLF(\r\n
)がないケースに対応しています。
変更されたファイルは以下の通りです。
src/pkg/mime/multipart/multipart.go
: マルチパートメッセージの解析ロジックを実装する主要なファイルです。空のパートの検出と処理に関する変更が含まれています。src/pkg/mime/multipart/multipart_test.go
:mime/multipart
パッケージのテストファイルです。今回の修正に対応するための新しいテストケースが多数追加されています。
コミット
Go言語の mime/multipart
パッケージにおいて、空のマルチパートが次の境界線の前にCRLFを持たない場合(ケースb)の処理が修正されました。これにより、RFCの解釈が曖昧な部分や、App Engineのような特定の環境で生成されるマルチパートボディの形式(ケースb)にも対応できるようになり、堅牢性が向上しました。多数のテストが追加され、変更の正当性が検証されています。
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/e393a8292ead03c78b570cf1f30ca1d54caf5445
元コミット内容
commit e393a8292ead03c78b570cf1f30ca1d54caf5445
Author: Brad Fitzpatrick <bradfitz@golang.org>
Date: Mon May 14 18:16:47 2012 -0700
mime/multipart: fix handling of empty parts without CRLF before next part
Empty parts can be either of the form:
a) "--separator\r\n", header (w/ trailing 2xCRLF), \r\n "--separator"...
or
b) "--separator\r\n", header (w/ trailing 2xCRLF), "--separator"...
We never handled case b). In fact the RFC seems kinda vague about
it, but browsers seem to do a), and App Engine's synthetic POST
bodies after blob uploads is of form b).
So handle them both, and add a bunch of tests.
(I can't promise these are the last fixes to multipart, especially
considering its history, but I'm growing increasingly confident at
least, and I've never submitted a multipart CL with known bugs
outstanding, including this time.)
R=golang-dev, adg
CC=golang-dev
https://golang.org/cl/6212046
変更の背景
この変更の背景には、マルチパートメッセージの「空のパート」の扱いに関する既存の実装の不備がありました。マルチパートメッセージは、HTTP POSTリクエストでのファイルアップロードや、MIMEメールなどで複数の異なるデータタイプを一つのメッセージボディに含める際に使用されます。各データは「パート」として区切られ、それぞれのパートはヘッダーとボディを持ちます。
問題は、ボディが空のパートの終端処理にありました。RFC(Request for Comments)は、マルチパートのフォーマットについて規定していますが、空のパートの終端、特に次の境界線が続く場合の改行コードの扱いに曖昧さがありました。
具体的には、空のパートの後に続く境界線が、以下の2つの形式のいずれかで現れる可能性がありました。
- ケースa):
--separator\r\n
(境界線) + ヘッダー (末尾に2つのCRLF) +\r\n
(追加のCRLF) +--separator
... - ケースb):
--separator\r\n
(境界線) + ヘッダー (末尾に2つのCRLF) +--separator
...
これまでの mime/multipart
パッケージの実装では、ケースa) の形式は正しく処理できましたが、ケースb) の形式は処理できませんでした。RFCの曖昧さにもかかわらず、一般的なブラウザはケースa) の形式で空のパートを送信する傾向がありました。しかし、Google App Engineのような特定のシステムでは、BLOBアップロード後の合成POSTボディがケースb) の形式で生成されることが判明しました。
この不一致により、App Engineから送信された特定のマルチパートメッセージがGoの mime/multipart
パッケージで正しく解析できないというバグが発生しました。このコミットは、この問題を解決し、両方の形式の空のパートを適切に処理できるようにすることで、パッケージの互換性と堅牢性を向上させることを目的としています。
前提知識の解説
MIME (Multipurpose Internet Mail Extensions)
MIMEは、電子メールでASCII文字以外のデータ(画像、音声、動画、アプリケーションファイルなど)や、複数のパートからなるメッセージを送信するための標準です。HTTPなどの他のインターネットプロトコルでも広く利用されています。
マルチパートメッセージ (Multipart Messages)
マルチパートメッセージは、単一のメッセージボディ内に複数の異なるデータセクション(パート)を含むことができるMIMEタイプの一種です。各パートは独自の Content-Type
ヘッダーを持ち、異なる種類のデータを表現できます。
構造
マルチパートメッセージは、Content-Type
ヘッダーで multipart/form-data
や multipart/mixed
などのサブタイプと、各パートを区切るための boundary
文字列を指定します。
例: Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryExample
メッセージボディ内では、この boundary
文字列が各パートの区切りとして使用されます。
- 開始境界線:
--
+boundary
+CRLF
(\r\n
) - パートヘッダー: 各パートのヘッダー(例:
Content-Disposition
,Content-Type
)が続きます。ヘッダーの終わりは空行(CRLFCRLF
)で示されます。 - パートボディ: ヘッダーの後に実際のデータが続きます。
- 次のパートへの境界線:
CRLF
+--
+boundary
+CRLF
- 終端境界線:
CRLF
+--
+boundary
+--
+CRLF
空のパート (Empty Parts)
パートのボディが空である場合、ヘッダーの後にデータが続かず、すぐに次の境界線が現れます。この「すぐに」という部分の解釈が、RFCで曖昧な点でした。
- RFC 2046 (Section 5.1.1): マルチパートボディの構文を定義しています。各パートは境界線で始まり、ヘッダーが続き、空行の後にボディが続きます。次の境界線は、ボディの後に
CRLF
と共に現れるとされています。しかし、ボディが空の場合にこのCRLF
が必須であるかどうかが明確ではありませんでした。
CRLF (\r\n
)
CRLFは、Carriage Return (CR, \r
, ASCII 13) と Line Feed (LF, \n
, ASCII 10) の組み合わせで、多くのインターネットプロトコル(HTTP, SMTP, FTPなど)で標準的な行末記号として使用されます。Windowsシステムでもテキストファイルの行末として使われます。Unix/LinuxシステムではLF (\n
) のみが一般的です。
io.Reader
と bufio.Reader
Go言語の io.Reader
インターフェースは、データの読み込み操作を抽象化します。bufio.Reader
は、io.Reader
をラップしてバッファリング機能を追加し、効率的な読み込み(特に1行ずつ読み込む場合など)を可能にします。Peek
メソッドは、実際に読み込むことなく、バッファの先頭から指定されたバイト数を覗き見ることができます。これは、次に続くデータが特定のパターン(例: 境界線)であるかどうかを事前に確認するのに役立ちます。
技術的詳細
このコミットの技術的な核心は、mime/multipart
パッケージがマルチパートメッセージの「空のパート」をどのように認識し、処理するかという点にあります。
従来の mime/multipart
の実装では、パートのボディを読み込む際、次の境界線が \r\n--boundary
の形式で現れることを期待していました。これは、空のパートであっても、ヘッダーの終端を示す \r\n\r\n
の後に、さらに \r\n
が続き、その後に境界線が来ると想定していたためです(上記の「ケースa」)。
しかし、App Engineのような一部のシステムでは、空のパートのボディが完全に空であり、ヘッダーの終端 \r\n\r\n
の直後に \r\n
を挟まずに次の境界線 --boundary
が続く形式(上記の「ケースb」)でメッセージを生成していました。
このコミットは、このケースb) に対応するために、Part.Read
メソッドと Reader
構造体に新しいロジックを導入しています。
-
Part.Read
メソッドの変更:Part.Read
は、現在のパートのボディを読み込むためのメソッドです。- このメソッドは、
p.bytesRead
という新しいフィールドを導入し、現在のパートで既に読み込んだバイト数を追跡します。 - 最も重要な変更は、
p.bytesRead == 0
(つまり、まだ現在のパートから何も読み込んでいない状態) かつ、p.mr.peekBufferIsEmptyPart(peek)
がtrue
を返す場合に、即座にio.EOF
を返すようにした点です。これは、現在のパートが空であり、かつ次の境界線が特定の形式で現れた場合に、そのパートの読み込みを終了させるためのものです。
-
Reader.peekBufferIsEmptyPart
メソッドの追加:- この新しいメソッドは、
bufio.Reader.Peek
で取得したバッファの内容を検査し、それが「空のパート」の終端パターン(ケースb)に合致するかどうかを判断します。 - 具体的には、
peek
バッファがmr.dashBoundaryDash
(--boundary--
終端境界線) またはmr.dashBoundary
(--boundary
通常の境界線) で始まるかどうかをチェックします。 - さらに、境界線の後に続く空白文字(
skipLWSPChar
でスキップされる)の後に、mr.nl
(\r\n
または\n
) が続くか、またはバッファの終端であるかを検証します。これにより、--boundaryFAKE
のような、データの一部として境界線に似た文字列が含まれるケースと区別します。 - このメソッドは、まだパートのボディが読み込まれていない状態で、次のデータが境界線である場合に
true
を返すことで、空のパートを正しく検出します。
- この新しいメソッドは、
-
Reader.isFinalBoundary
メソッドの変更:- 終端境界線 (
--boundary--
) の検出ロジックが改善されました。以前はbytes.Equal(line, r.dashBoundaryDash)
のみでチェックしていましたが、終端境界線の後に空白文字や\r\n
が続く場合も考慮するようにskipLWSPChar
とbytes.Equal(rest, mr.nl)
を使用して堅牢化されました。
- 終端境界線 (
-
Reader.isBoundaryDelimiterLine
メソッドの変更:- 境界線デリミタ行の検出ロジックが改善されました。特に、最初のパートの解析時に、行末が
\n
のみである場合(RFC違反だが実運用で発生するケース)に、mr.nl
を\n
に切り替えるロジックが追加されました。これにより、より柔軟な改行コードの扱いに対応しています。
- 境界線デリミタ行の検出ロジックが改善されました。特に、最初のパートの解析時に、行末が
-
ユーティリティ関数の変更/削除:
lf
(\n
) とcrlf
(\r\n
) のグローバル変数が削除され、Reader
構造体のフィールドnl
に統合されました。これは、改行コードの検出ロジックがより動的になったためです。onlyHorizontalWhitespace
とhasPrefixThenNewline
関数が削除されました。これらの機能は、新しいskipLWSPChar
やisFinalBoundary
、peekBufferIsEmptyPart
メソッドに統合または置き換えられました。
これらの変更により、mime/multipart
パッケージは、RFCの厳密な解釈だけでなく、実際の運用で発生する様々な形式のマルチパートメッセージ、特に空のパートの終端処理において、より柔軟かつ正確に動作するようになりました。
コアとなるコードの変更箇所
src/pkg/mime/multipart/multipart.go
--- a/src/pkg/mime/multipart/multipart.go
+++ b/src/pkg/mime/multipart/multipart.go
@@ -22,11 +22,6 @@ import (
"net/textproto"
)
-// TODO(bradfitz): inline these once the compiler can inline them in
-// read-only situation (such as bytes.HasSuffix)
-var lf = []byte("\n")
-var crlf = []byte("\r\n")
-
var emptyParams = make(map[string]string)
// A Part represents a single part in a multipart body.
@@ -36,8 +31,9 @@ type Part struct {
// i.e. "foo-bar" changes case to "Foo-Bar"
Header textproto.MIMEHeader
- buffer *bytes.Buffer
- mr *Reader
+ buffer *bytes.Buffer
+ mr *Reader
+ bytesRead int
disposition string
dispositionParams map[string]string
@@ -113,14 +109,26 @@ func (bp *Part) populateHeaders() error {
// Read reads the body of a part, after its headers and before the
// next part (if any) begins.
func (p *Part) Read(d []byte) (n int, err error) {
+ defer func() {
+ p.bytesRead += n
+ }()
if p.buffer.Len() >= len(d) {
// Internal buffer of unconsumed data is large enough for
// the read request. No need to parse more at the moment.
return p.buffer.Read(d)
}
peek, err := p.mr.bufReader.Peek(4096) // TODO(bradfitz): add buffer size accessor
- unexpectedEof := err == io.EOF
- if err != nil && !unexpectedEof {
+
+ // Look for an immediate empty part without a leading \r\n
+ // before the boundary separator. Some MIME code makes empty
+ // parts like this. Most browsers, however, write the \r\n
+ // before the subsequent boundary even for empty parts and
+ // won't hit this path.
+ if p.bytesRead == 0 && p.mr.peekBufferIsEmptyPart(peek) {
+ return 0, io.EOF
+ }
+ unexpectedEOF := err == io.EOF
+ if err != nil && !unexpectedEOF {
return 0, fmt.Errorf("multipart: Part Read: %v", err)
}
if peek == nil {
@@ -138,7 +146,7 @@ func (p *Part) Read(d []byte) (n int, err error) {
foundBoundary = true
} else if safeCount := len(peek) - len(p.mr.nlDashBoundary); safeCount > 0 {
nCopy = safeCount
- } else if unexpectedEof {
+ } else if unexpectedEOF {
// If we've run out of peek buffer and the boundary
// wasn't found (and can't possibly fit), we must have
// hit the end of the file unexpectedly.
@@ -159,7 +167,10 @@ type Reader struct {
currentPart *Part
partsRead int
- nl, nlDashBoundary, dashBoundaryDash, dashBoundary []byte
+ nl []byte // "\r\n" or "\n" (set after seeing first boundary line)
+ nlDashBoundary []byte // nl + "--boundary"
+ dashBoundaryDash []byte // "--boundary--"
+ dashBoundary []byte // "--boundary"
}
// NextPart returns the next part in the multipart or an error.
@@ -172,7 +183,7 @@ func (r *Reader) NextPart() (*Part, error) {
expectNewPart := false
for {
line, err := r.bufReader.ReadSlice('\n')
- if err == io.EOF && bytes.Equal(line, r.dashBoundaryDash) {
+ if err == io.EOF && r.isFinalBoundary(line) {
// If the buffer ends in "--boundary--" without the
// trailing "\r\n", ReadSlice will return an error
// (since it's missing the '\n'), but this is a valid
@@ -194,7 +205,7 @@ func (r *Reader) NextPart() (*Part, error) {
return bp, nil
}
- if hasPrefixThenNewline(line, r.dashBoundaryDash) {
+ if r.isFinalBoundary(line) {
// Expected EOF
return nil, io.EOF
}
@@ -222,32 +233,52 @@ func (r *Reader) NextPart() (*Part, error) {
panic("unreachable")
}
-func (mr *Reader) isBoundaryDelimiterLine(line []byte) bool {
+// isFinalBoundary returns whether line is the final boundary line
+// indiciating that all parts are over.
+// It matches `^--boundary--[ \t]*(\r\n)?$`
+func (mr *Reader) isFinalBoundary(line []byte) bool {
+ if !bytes.HasPrefix(line, mr.dashBoundaryDash) {
+ return false
+ }
+ rest := line[len(mr.dashBoundaryDash):]
+ rest = skipLWSPChar(rest)
+ return len(rest) == 0 || bytes.Equal(rest, mr.nl)
+}
+
+func (mr *Reader) isBoundaryDelimiterLine(line []byte) (ret bool) {
// http://tools.ietf.org/html/rfc2046#section-5.1
// The boundary delimiter line is then defined as a line
// consisting entirely of two hyphen characters ("-",
// ASCII 45) followed by the boundary parameter value from the
// Content-Type header field, optionally followed by one or more
// linear whitespace characters, a CRLF, and then anything else.
if !bytes.HasPrefix(line, mr.dashBoundary) {
return false
}
- if bytes.HasSuffix(line, mr.nl) {
- return onlyHorizontalWhitespace(line[len(mr.dashBoundary) : len(line)-len(mr.nl)])
- }
- // Violate the spec and also support newlines without the
- // carriage return...
- if mr.partsRead == 0 && bytes.HasSuffix(line, lf) {
- if onlyHorizontalWhitespace(line[len(mr.dashBoundary) : len(line)-1]) {
- mr.nl = mr.nl[1:]
- mr.nlDashBoundary = mr.nlDashBoundary[1:]
- return true
- }
- }
- return false
+ rest := line[len(mr.dashBoundary):]
+ rest = skipLWSPChar(rest)
+
+ // On the first part, see our lines are ending in \n instead of \r\n
+ // and switch into that mode if so. This is a violation of the spec,
+ // but occurs in practice.
+ if mr.partsRead == 0 && len(rest) == 1 && rest[0] == '\n' {
+ mr.nl = mr.nl[1:]
+ mr.nlDashBoundary = mr.nlDashBoundary[1:]
+ }
+ return bytes.Equal(rest, mr.nl)
}
-func onlyHorizontalWhitespace(s []byte) bool {
- for _, b := range s {
- if b != ' ' && b != '\t' {
- return false
- }
+// peekBufferIsEmptyPart returns whether the provided peek-ahead
+// buffer represents an empty part. This is only called if we've not
+// already read any bytes in this part and checks for the case of MIME
+// software not writing the \r\n on empty parts. Some does, some
+// doesn't.
+//
+// This checks that what follows the "--boundary" is actually the end
+// ("--boundary--" with optional whitespace) or optional whitespace
+// and then a newline, so we don't catch "--boundaryFAKE", in which
+// case the whole line is part of the data.
+func (mr *Reader) peekBufferIsEmptyPart(peek []byte) bool {
+ // End of parts case.
+ // Test whether peek matches `^--boundary--[ \t]*(?:\\r\\n|$)`
+ if bytes.HasPrefix(peek, mr.dashBoundaryDash) {
+ rest := peek[len(mr.dashBoundaryDash):]
+ rest = skipLWSPChar(rest)
+ return bytes.HasPrefix(rest, mr.nl) || len(rest) == 0
}
- return true
+ if !bytes.HasPrefix(peek, mr.dashBoundary) {
+ return false
+ }
+ // Test whether rest matches `^[ \t]*\r\n`)
+ rest := peek[len(mr.dashBoundary):]
+ rest = skipLWSPChar(rest)
+ return bytes.HasPrefix(rest, mr.nl)
}
-func hasPrefixThenNewline(s, prefix []byte) bool {
- return bytes.HasPrefix(s, prefix) &&
- (len(s) == len(prefix)+1 && s[len(s)-1] == '\n' ||
- len(s) == len(prefix)+2 && bytes.HasSuffix(s, crlf))
+// skipLWSPChar returns b with leading spaces and tabs removed.
+// RFC 822 defines:
+// LWSP-char = SPACE / HTAB
+func skipLWSPChar(b []byte) []byte {
+ for len(b) > 0 && (b[0] == ' ' || b[0] == '\t') {
+ b = b[1:]
+ }
+ return b
}
src/pkg/mime/multipart/multipart_test.go
TestHorizontalWhitespace
関数が削除されました。TestZeroLengthBody
関数が削除されました。parseTest
構造体とparseTests
スライスが追加され、様々な形式のマルチパートボディ(特に空のパートや、境界線の直後に続くケースb)を網羅する多数のテストケースが定義されました。TestParse
関数が追加され、parseTests
に定義されたテストケースをループで実行し、NewReader
で解析した結果が期待値と一致するかをreflect.DeepEqual
で厳密に比較しています。formData
ヘルパー関数が追加され、テストケースのheaderBody
を簡単に生成できるようになりました。roundTripParseTest
関数が追加され、mime/multipart
のWriter
で書き込んだマルチパートボディをReader
で読み込み、正しくラウンドトリップできるかを確認するテストケースを生成しています。
コアとなるコードの解説
このコミットの主要な変更は、mime/multipart
パッケージが空のパートを検出する方法と、その後の境界線を処理する方法を改善した点にあります。
-
Part.bytesRead
フィールドの導入:Part
構造体にbytesRead int
フィールドが追加されました。これは、現在のパートのボディから既に読み込まれたバイト数を追跡するために使用されます。Part.Read
メソッドが呼び出されるたびに、読み込まれたバイト数n
がp.bytesRead
に加算されます。このフィールドは、パートの読み込みが開始されたばかり(p.bytesRead == 0
)であるかどうかを判断するのに重要です。 -
Part.Read
における空のパートの早期検出:Part.Read
メソッドの冒頭に、以下の重要なロジックが追加されました。defer func() { p.bytesRead += n }() // ... if p.bytesRead == 0 && p.mr.peekBufferIsEmptyPart(peek) { return 0, io.EOF }
defer
ステートメントにより、Part.Read
が終了する際に必ずp.bytesRead
が更新されるようになっています。p.bytesRead == 0
は、このRead
呼び出しが現在のパートに対する最初の読み込みであるか、または以前の読み込みで0バイトしか読み込まれていないことを意味します。p.mr.peekBufferIsEmptyPart(peek)
は、bufio.Reader.Peek
で取得した先行読み込みバッファpeek
を検査し、次に続くデータが「空のパート」の終端パターン(特に、\r\n
を挟まない境界線)であるかどうかを判断します。- もし両方の条件が真であれば、現在のパートは空であると判断され、
0, io.EOF
が返されます。これにより、mime/multipart
は、ボディが空のパートを正しく認識し、次のパートの解析に進むことができます。これは、App Engineのようなシステムが生成する「ケースb」のマルチパートボディを処理するために不可欠な変更です。
-
Reader.peekBufferIsEmptyPart
の詳細: この新しいメソッドは、Part.Read
から呼び出され、peek
バッファの内容を解析して、空のパートの終端パターンを検出します。func (mr *Reader) peekBufferIsEmptyPart(peek []byte) bool { // End of parts case. // Test whether peek matches `^--boundary--[ \t]*(?:\\r\\n|$)` if bytes.HasPrefix(peek, mr.dashBoundaryDash) { // 終端境界線 (--boundary--) の場合 rest := peek[len(mr.dashBoundaryDash):] rest = skipLWSPChar(rest) // 後続の空白文字をスキップ return bytes.HasPrefix(rest, mr.nl) || len(rest) == 0 // \r\n が続くか、バッファの終端であればtrue } if !bytes.HasPrefix(peek, mr.dashBoundary) { // 通常の境界線 (--boundary) で始まらない場合 return false } // Test whether rest matches `^[ \t]*\r\n`) rest := peek[len(mr.dashBoundary):] rest = skipLWSPChar(rest) // 後続の空白文字をスキップ return bytes.HasPrefix(rest, mr.nl) // \r\n が続けばtrue }
この関数は、
peek
バッファが終端境界線 (--boundary--
) または通常の境界線 (--boundary
) で始まるかどうかをチェックします。さらに、境界線の後に続く空白文字をスキップし、その後に\r\n
(または\n
、mr.nl
の値による) が続くか、あるいはバッファがそこで終了しているかを検証します。これにより、データの一部として境界線に似た文字列が含まれる場合(例:--boundaryFAKE
)と、実際に次のパートの境界線が来ている場合を正確に区別できます。 -
Reader.nl
フィールドの動的な設定:Reader
構造体内のnl
フィールドは、改行コード (\r\n
または\n
) を表します。isBoundaryDelimiterLine
メソッド内で、最初のパートを解析する際に、もし行末が\n
のみである(RFC違反だが実運用で発生する)と検出された場合、mr.nl
が\n
に変更されます。これにより、Goのmime/multipart
は、より多様な改行コードの慣習に対応できるようになります。
これらの変更により、mime/multipart
パッケージは、RFCの厳密な解釈だけでなく、実際の運用で発生する様々な形式のマルチパートメッセージ、特に空のパートの終端処理において、より柔軟かつ正確に動作するようになりました。追加された広範なテストケースは、これらの新しいロジックが様々なエッジケースで正しく機能することを保証しています。
関連リンク
- Go CL 6212046: https://golang.org/cl/6212046
参考にした情報源リンク
- RFC 2046 - MIME Part Two: Media Types: https://datatracker.ietf.org/doc/html/rfc2046
- RFC 822 - STANDARD FOR THE FORMAT OF ARPA INTERNET TEXT MESSAGES: https://datatracker.ietf.org/doc/html/rfc822 (LWSP-char の定義について)
- Go言語の
bytes
パッケージドキュメント: https://pkg.go.dev/bytes - Go言語の
io
パッケージドキュメント: https://pkg.go.dev/io - Go言語の
bufio
パッケージドキュメント: https://pkg.go.dev/bufio - Go言語の
net/textproto
パッケージドキュメント: https://pkg.go.dev/net/textproto - Go言語の
mime/multipart
パッケージドキュメント: https://pkg.go.dev/mime/multipart - MIME (Multipurpose Internet Mail Extensions) - Wikipedia: https://ja.wikipedia.org/wiki/MIME
- HTTP multipart/form-data - MDN Web Docs: https://developer.mozilla.org/ja/docs/Web/HTTP/Methods/POST (multipart/form-data の例)