Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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-datamultipart/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.Readerbufio.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 構造体に新しいロジックを導入しています。

  1. Part.Read メソッドの変更:

    • Part.Read は、現在のパートのボディを読み込むためのメソッドです。
    • このメソッドは、p.bytesRead という新しいフィールドを導入し、現在のパートで既に読み込んだバイト数を追跡します。
    • 最も重要な変更は、p.bytesRead == 0 (つまり、まだ現在のパートから何も読み込んでいない状態) かつ、p.mr.peekBufferIsEmptyPart(peek)true を返す場合に、即座に io.EOF を返すようにした点です。これは、現在のパートが空であり、かつ次の境界線が特定の形式で現れた場合に、そのパートの読み込みを終了させるためのものです。
  2. Reader.peekBufferIsEmptyPart メソッドの追加:

    • この新しいメソッドは、bufio.Reader.Peek で取得したバッファの内容を検査し、それが「空のパート」の終端パターン(ケースb)に合致するかどうかを判断します。
    • 具体的には、peek バッファが mr.dashBoundaryDash (--boundary-- 終端境界線) または mr.dashBoundary (--boundary 通常の境界線) で始まるかどうかをチェックします。
    • さらに、境界線の後に続く空白文字(skipLWSPChar でスキップされる)の後に、mr.nl (\r\n または \n) が続くか、またはバッファの終端であるかを検証します。これにより、--boundaryFAKE のような、データの一部として境界線に似た文字列が含まれるケースと区別します。
    • このメソッドは、まだパートのボディが読み込まれていない状態で、次のデータが境界線である場合に true を返すことで、空のパートを正しく検出します。
  3. Reader.isFinalBoundary メソッドの変更:

    • 終端境界線 (--boundary--) の検出ロジックが改善されました。以前は bytes.Equal(line, r.dashBoundaryDash) のみでチェックしていましたが、終端境界線の後に空白文字や \r\n が続く場合も考慮するように skipLWSPCharbytes.Equal(rest, mr.nl) を使用して堅牢化されました。
  4. Reader.isBoundaryDelimiterLine メソッドの変更:

    • 境界線デリミタ行の検出ロジックが改善されました。特に、最初のパートの解析時に、行末が \n のみである場合(RFC違反だが実運用で発生するケース)に、mr.nl\n に切り替えるロジックが追加されました。これにより、より柔軟な改行コードの扱いに対応しています。
  5. ユーティリティ関数の変更/削除:

    • lf (\n) と crlf (\r\n) のグローバル変数が削除され、Reader 構造体のフィールド nl に統合されました。これは、改行コードの検出ロジックがより動的になったためです。
    • onlyHorizontalWhitespacehasPrefixThenNewline 関数が削除されました。これらの機能は、新しい skipLWSPCharisFinalBoundarypeekBufferIsEmptyPart メソッドに統合または置き換えられました。

これらの変更により、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/multipartWriter で書き込んだマルチパートボディを Reader で読み込み、正しくラウンドトリップできるかを確認するテストケースを生成しています。

コアとなるコードの解説

このコミットの主要な変更は、mime/multipart パッケージが空のパートを検出する方法と、その後の境界線を処理する方法を改善した点にあります。

  1. Part.bytesRead フィールドの導入: Part 構造体に bytesRead int フィールドが追加されました。これは、現在のパートのボディから既に読み込まれたバイト数を追跡するために使用されます。Part.Read メソッドが呼び出されるたびに、読み込まれたバイト数 np.bytesRead に加算されます。このフィールドは、パートの読み込みが開始されたばかり(p.bytesRead == 0)であるかどうかを判断するのに重要です。

  2. 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」のマルチパートボディを処理するために不可欠な変更です。
  3. 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 (または \nmr.nl の値による) が続くか、あるいはバッファがそこで終了しているかを検証します。これにより、データの一部として境界線に似た文字列が含まれる場合(例: --boundaryFAKE)と、実際に次のパートの境界線が来ている場合を正確に区別できます。

  4. Reader.nl フィールドの動的な設定: Reader 構造体内の nl フィールドは、改行コード (\r\n または \n) を表します。isBoundaryDelimiterLine メソッド内で、最初のパートを解析する際に、もし行末が \n のみである(RFC違反だが実運用で発生する)と検出された場合、mr.nl\n に変更されます。これにより、Goの mime/multipart は、より多様な改行コードの慣習に対応できるようになります。

これらの変更により、mime/multipart パッケージは、RFCの厳密な解釈だけでなく、実際の運用で発生する様々な形式のマルチパートメッセージ、特に空のパートの終端処理において、より柔軟かつ正確に動作するようになりました。追加された広範なテストケースは、これらの新しいロジックが様々なエッジケースで正しく機能することを保証しています。

関連リンク

参考にした情報源リンク