From 66d16d72b937b4b51720a036958c011a7b1725b3 Mon Sep 17 00:00:00 2001 From: Michael Pasquale Date: Fri, 21 Aug 2026 16:37:05 -0700 Subject: [PATCH 1/5] textproto: fix matchAfterPrefix false-positive for hyphen-extended inner boundary Co-Authored-By: Claude Sonnet 4.6 --- textproto/multipart.go | 16 +++++++++++++++- textproto/multipart_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/textproto/multipart.go b/textproto/multipart.go index 2d89d5b..2a7ac97 100644 --- a/textproto/multipart.go +++ b/textproto/multipart.go @@ -218,9 +218,23 @@ func matchAfterPrefix(buf, prefix []byte, readErr error) int { return 0 } c := buf[len(prefix)] - if c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '-' { + if c == ' ' || c == '\t' || c == '\r' || c == '\n' { return +1 } + if c == '-' { + // '--boundary--' is the closing delimiter; '--boundary-suffix' is a longer, + // different boundary. Peek one more byte to tell them apart. + if len(buf) == len(prefix)+1 { + if readErr != nil { + return +1 // single '-' at EOF: treat as closing delimiter + } + return 0 // need one more byte to decide + } + if buf[len(prefix)+1] == '-' { + return +1 // '--boundary--': genuine closing delimiter + } + return -1 // '--boundary-X': longer boundary, not a match for this one + } return -1 } diff --git a/textproto/multipart_test.go b/textproto/multipart_test.go index 096fdf7..b4d3ad4 100644 --- a/textproto/multipart_test.go +++ b/textproto/multipart_test.go @@ -679,6 +679,39 @@ html things }, }, }, + // Hyphen-digit suffixed inner boundaries (mirrors real-world case where outer=foo, + // inner=foo-5, inner-inner=foo-1). Neither --foo-5 nor --foo-1 should be matched + // as --foo's closing delimiter. + { + name: "inner boundaries with multiple hyphen-digit suffixes", + sep: "foo", + in: strings.Replace(`--foo +Content-Type: multipart/alternative; boundary="foo-5" + +--foo-5 +Content-Type: multipart/related; boundary="foo-1" + +--foo-1 +Content-Type: text/html + +hello +--foo-1-- +--foo-5-- +--foo--`, "\n", "\r\n", -1), + want: []headerBody{ + {textproto.MIMEHeader{"Content-Type": {`multipart/alternative; boundary="foo-5"`}}, + strings.Replace(`--foo-5 +Content-Type: multipart/related; boundary="foo-1" + +--foo-1 +Content-Type: text/html + +hello +--foo-1-- +--foo-5--`, "\n", "\r\n", -1), + }, + }, + }, // Issue 12662: Check that we don't consume the leading \r if the peekBuffer // ends in '\r\n--separator-' { From 20a5be43e47eb7cfef8ce05bcad374244ed197c6 Mon Sep 17 00:00:00 2001 From: Michael Pasquale Date: Fri, 21 Aug 2026 17:01:45 -0700 Subject: [PATCH 2/5] textproto: add nested multipart test verifying content at all three boundary levels Co-Authored-By: Claude Sonnet 4.6 --- textproto/multipart_test.go | 61 +++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/textproto/multipart_test.go b/textproto/multipart_test.go index b4d3ad4..3caa628 100644 --- a/textproto/multipart_test.go +++ b/textproto/multipart_test.go @@ -921,3 +921,64 @@ func TestInvalidLineAfterBoundary(t *testing.T) { t.Error("Expected an error when parsing invalid line after boundary, got nil") } } + +// TestNestedMultipartHyphenBoundaries exercises the real-world case where inner +// boundaries are the outer boundary plus a hyphen-digit suffix (e.g. foo-5, foo-1). +// It reads all three levels and asserts each part has non-empty content. +func TestNestedMultipartHyphenBoundaries(t *testing.T) { + const input = "--foo\r\n" + + "Content-Type: multipart/alternative; boundary=\"foo-5\"\r\n" + + "\r\n" + + "--foo-5\r\n" + + "Content-Type: multipart/related; boundary=\"foo-1\"\r\n" + + "\r\n" + + "--foo-1\r\n" + + "Content-Type: text/html\r\n" + + "\r\n" + + "hello\r\n" + + "--foo-1--\r\n" + + "--foo-5--\r\n" + + "--foo--\r\n" + + // Level 1: outer boundary "foo" — expect one part. + outerPart, err := NewMultipartReader(strings.NewReader(input), "foo").NextPart() + if err != nil { + t.Fatalf("outer NextPart: %v", err) + } + outerBody, err := io.ReadAll(outerPart) + if err != nil { + t.Fatalf("reading outer part body: %v", err) + } + if len(outerBody) == 0 { + t.Fatal("outer part body is empty") + } + + // Level 2: middle boundary "foo-5" — expect one part. + middlePart, err := NewMultipartReader(bytes.NewReader(outerBody), "foo-5").NextPart() + if err != nil { + t.Fatalf("middle NextPart: %v", err) + } + middleBody, err := io.ReadAll(middlePart) + if err != nil { + t.Fatalf("reading middle part body: %v", err) + } + if len(middleBody) == 0 { + t.Fatal("middle part body is empty") + } + + // Level 3: inner boundary "foo-1" — expect one part with the HTML body. + innerPart, err := NewMultipartReader(bytes.NewReader(middleBody), "foo-1").NextPart() + if err != nil { + t.Fatalf("inner NextPart: %v", err) + } + if got := innerPart.Header.Get("Content-Type"); got != "text/html" { + t.Errorf("inner part Content-Type = %q, want %q", got, "text/html") + } + innerBody, err := io.ReadAll(innerPart) + if err != nil { + t.Fatalf("reading inner part body: %v", err) + } + if got, want := string(innerBody), "hello"; got != want { + t.Errorf("inner part body = %q, want %q", got, want) + } +} From 908c00cbe7ff733639f1f05bb714c584cb273ace Mon Sep 17 00:00:00 2001 From: Michael Pasquale Date: Fri, 21 Aug 2026 17:06:19 -0700 Subject: [PATCH 3/5] textproto: assert exact header and body content at each nested boundary level Co-Authored-By: Claude Sonnet 4.6 --- textproto/multipart_test.go | 42 +++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/textproto/multipart_test.go b/textproto/multipart_test.go index 3caa628..c997c64 100644 --- a/textproto/multipart_test.go +++ b/textproto/multipart_test.go @@ -924,7 +924,7 @@ func TestInvalidLineAfterBoundary(t *testing.T) { // TestNestedMultipartHyphenBoundaries exercises the real-world case where inner // boundaries are the outer boundary plus a hyphen-digit suffix (e.g. foo-5, foo-1). -// It reads all three levels and asserts each part has non-empty content. +// It reads all three levels and asserts the exact header and body at each level. func TestNestedMultipartHyphenBoundaries(t *testing.T) { const input = "--foo\r\n" + "Content-Type: multipart/alternative; boundary=\"foo-5\"\r\n" + @@ -940,45 +940,65 @@ func TestNestedMultipartHyphenBoundaries(t *testing.T) { "--foo-5--\r\n" + "--foo--\r\n" - // Level 1: outer boundary "foo" — expect one part. + // Level 1: outer boundary "foo". outerPart, err := NewMultipartReader(strings.NewReader(input), "foo").NextPart() if err != nil { t.Fatalf("outer NextPart: %v", err) } + if got, want := outerPart.Header.Get("Content-Type"), `multipart/alternative; boundary="foo-5"`; got != want { + t.Errorf("outer Content-Type = %q, want %q", got, want) + } outerBody, err := io.ReadAll(outerPart) if err != nil { t.Fatalf("reading outer part body: %v", err) } - if len(outerBody) == 0 { - t.Fatal("outer part body is empty") + wantOuterBody := "--foo-5\r\n" + + "Content-Type: multipart/related; boundary=\"foo-1\"\r\n" + + "\r\n" + + "--foo-1\r\n" + + "Content-Type: text/html\r\n" + + "\r\n" + + "hello\r\n" + + "--foo-1--\r\n" + + "--foo-5--" + if got := string(outerBody); got != wantOuterBody { + t.Errorf("outer body = %q, want %q", got, wantOuterBody) } - // Level 2: middle boundary "foo-5" — expect one part. + // Level 2: middle boundary "foo-5". middlePart, err := NewMultipartReader(bytes.NewReader(outerBody), "foo-5").NextPart() if err != nil { t.Fatalf("middle NextPart: %v", err) } + if got, want := middlePart.Header.Get("Content-Type"), `multipart/related; boundary="foo-1"`; got != want { + t.Errorf("middle Content-Type = %q, want %q", got, want) + } middleBody, err := io.ReadAll(middlePart) if err != nil { t.Fatalf("reading middle part body: %v", err) } - if len(middleBody) == 0 { - t.Fatal("middle part body is empty") + wantMiddleBody := "--foo-1\r\n" + + "Content-Type: text/html\r\n" + + "\r\n" + + "hello\r\n" + + "--foo-1--" + if got := string(middleBody); got != wantMiddleBody { + t.Errorf("middle body = %q, want %q", got, wantMiddleBody) } - // Level 3: inner boundary "foo-1" — expect one part with the HTML body. + // Level 3: inner boundary "foo-1". innerPart, err := NewMultipartReader(bytes.NewReader(middleBody), "foo-1").NextPart() if err != nil { t.Fatalf("inner NextPart: %v", err) } - if got := innerPart.Header.Get("Content-Type"); got != "text/html" { - t.Errorf("inner part Content-Type = %q, want %q", got, "text/html") + if got, want := innerPart.Header.Get("Content-Type"), "text/html"; got != want { + t.Errorf("inner Content-Type = %q, want %q", got, want) } innerBody, err := io.ReadAll(innerPart) if err != nil { t.Fatalf("reading inner part body: %v", err) } if got, want := string(innerBody), "hello"; got != want { - t.Errorf("inner part body = %q, want %q", got, want) + t.Errorf("inner body = %q, want %q", got, want) } } From 8f86fa6dcab67c860a79780078837622deeb38b4 Mon Sep 17 00:00:00 2001 From: Michael Pasquale Date: Mon, 24 Aug 2026 11:12:10 -0700 Subject: [PATCH 4/5] textproto: add TestMatchAfterPrefix unit test; make nested boundary test tabular Co-Authored-By: Claude Sonnet 4.6 --- textproto/multipart_test.go | 173 +++++++++++++++++++++--------------- 1 file changed, 101 insertions(+), 72 deletions(-) diff --git a/textproto/multipart_test.go b/textproto/multipart_test.go index c997c64..28a6be8 100644 --- a/textproto/multipart_test.go +++ b/textproto/multipart_test.go @@ -922,83 +922,112 @@ func TestInvalidLineAfterBoundary(t *testing.T) { } } +// TestMatchAfterPrefix exercises all return paths of matchAfterPrefix, including +// the new single-hyphen lookahead added to distinguish --boundary-- from --boundary-X. +func TestMatchAfterPrefix(t *testing.T) { + eof := io.ErrUnexpectedEOF + prefix := []byte("\r\n--foo") + + tests := []struct { + name string + buf []byte + readErr error + want int + }{ + {"buf equals prefix, no err", prefix, nil, 0}, + {"buf equals prefix, at EOF", prefix, eof, +1}, + {"space after prefix", append(prefix, ' '), nil, +1}, + {"tab after prefix", append(prefix, '\t'), nil, +1}, + {"CR after prefix", append(prefix, '\r'), nil, +1}, + {"LF after prefix", append(prefix, '\n'), nil, +1}, + {"other char after prefix", append(prefix, 'x'), nil, -1}, + {"closing delimiter --", append(prefix, '-', '-'), nil, +1}, + {"hyphen-digit suffix, not a match", append(prefix, '-', '1'), nil, -1}, + {"hyphen-letter suffix, not a match", append(prefix, '-', 'a'), nil, -1}, + {"single hyphen, no err: need more data", append(prefix, '-'), nil, 0}, + {"single hyphen at EOF: closing delimiter", append(prefix, '-'), eof, +1}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := matchAfterPrefix(tc.buf, prefix, tc.readErr) + if got != tc.want { + t.Errorf("matchAfterPrefix(%q, %q, %v) = %d, want %d", + tc.buf, prefix, tc.readErr, got, tc.want) + } + }) + } +} + // TestNestedMultipartHyphenBoundaries exercises the real-world case where inner // boundaries are the outer boundary plus a hyphen-digit suffix (e.g. foo-5, foo-1). // It reads all three levels and asserts the exact header and body at each level. func TestNestedMultipartHyphenBoundaries(t *testing.T) { - const input = "--foo\r\n" + - "Content-Type: multipart/alternative; boundary=\"foo-5\"\r\n" + - "\r\n" + - "--foo-5\r\n" + - "Content-Type: multipart/related; boundary=\"foo-1\"\r\n" + - "\r\n" + - "--foo-1\r\n" + - "Content-Type: text/html\r\n" + - "\r\n" + - "hello\r\n" + - "--foo-1--\r\n" + - "--foo-5--\r\n" + - "--foo--\r\n" - - // Level 1: outer boundary "foo". - outerPart, err := NewMultipartReader(strings.NewReader(input), "foo").NextPart() - if err != nil { - t.Fatalf("outer NextPart: %v", err) - } - if got, want := outerPart.Header.Get("Content-Type"), `multipart/alternative; boundary="foo-5"`; got != want { - t.Errorf("outer Content-Type = %q, want %q", got, want) - } - outerBody, err := io.ReadAll(outerPart) - if err != nil { - t.Fatalf("reading outer part body: %v", err) - } - wantOuterBody := "--foo-5\r\n" + - "Content-Type: multipart/related; boundary=\"foo-1\"\r\n" + - "\r\n" + - "--foo-1\r\n" + - "Content-Type: text/html\r\n" + - "\r\n" + - "hello\r\n" + - "--foo-1--\r\n" + - "--foo-5--" - if got := string(outerBody); got != wantOuterBody { - t.Errorf("outer body = %q, want %q", got, wantOuterBody) - } - - // Level 2: middle boundary "foo-5". - middlePart, err := NewMultipartReader(bytes.NewReader(outerBody), "foo-5").NextPart() - if err != nil { - t.Fatalf("middle NextPart: %v", err) - } - if got, want := middlePart.Header.Get("Content-Type"), `multipart/related; boundary="foo-1"`; got != want { - t.Errorf("middle Content-Type = %q, want %q", got, want) - } - middleBody, err := io.ReadAll(middlePart) - if err != nil { - t.Fatalf("reading middle part body: %v", err) - } - wantMiddleBody := "--foo-1\r\n" + - "Content-Type: text/html\r\n" + - "\r\n" + - "hello\r\n" + - "--foo-1--" - if got := string(middleBody); got != wantMiddleBody { - t.Errorf("middle body = %q, want %q", got, wantMiddleBody) + crlf := func(s string) string { return strings.Replace(s, "\n", "\r\n", -1) } + + input := crlf("--foo\n" + + "Content-Type: multipart/alternative; boundary=\"foo-5\"\n" + + "\n" + + "--foo-5\n" + + "Content-Type: multipart/related; boundary=\"foo-1\"\n" + + "\n" + + "--foo-1\n" + + "Content-Type: text/html\n" + + "\n" + + "hello\n" + + "--foo-1--\n" + + "--foo-5--\n" + + "--foo--\n") + + levels := []struct { + boundary string + wantContentType string + wantBody string + }{ + { + boundary: "foo", + wantContentType: `multipart/alternative; boundary="foo-5"`, + wantBody: crlf("--foo-5\n" + + "Content-Type: multipart/related; boundary=\"foo-1\"\n" + + "\n" + + "--foo-1\n" + + "Content-Type: text/html\n" + + "\n" + + "hello\n" + + "--foo-1--\n" + + "--foo-5--"), + }, + { + boundary: "foo-5", + wantContentType: `multipart/related; boundary="foo-1"`, + wantBody: crlf("--foo-1\n" + + "Content-Type: text/html\n" + + "\n" + + "hello\n" + + "--foo-1--"), + }, + { + boundary: "foo-1", + wantContentType: "text/html", + wantBody: "hello", + }, } - // Level 3: inner boundary "foo-1". - innerPart, err := NewMultipartReader(bytes.NewReader(middleBody), "foo-1").NextPart() - if err != nil { - t.Fatalf("inner NextPart: %v", err) - } - if got, want := innerPart.Header.Get("Content-Type"), "text/html"; got != want { - t.Errorf("inner Content-Type = %q, want %q", got, want) - } - innerBody, err := io.ReadAll(innerPart) - if err != nil { - t.Fatalf("reading inner part body: %v", err) - } - if got, want := string(innerBody), "hello"; got != want { - t.Errorf("inner body = %q, want %q", got, want) + body := []byte(input) + for _, level := range levels { + part, err := NewMultipartReader(bytes.NewReader(body), level.boundary).NextPart() + if err != nil { + t.Fatalf("NextPart for boundary %q: %v", level.boundary, err) + } + if got := part.Header.Get("Content-Type"); got != level.wantContentType { + t.Errorf("boundary %q: Content-Type = %q, want %q", level.boundary, got, level.wantContentType) + } + body, err = io.ReadAll(part) + if err != nil { + t.Fatalf("reading body for boundary %q: %v", level.boundary, err) + } + if got := string(body); got != level.wantBody { + t.Errorf("boundary %q: body = %q, want %q", level.boundary, got, level.wantBody) + } } } From 66406fd9a210f4c6dca8385a57bfddf053327a5e Mon Sep 17 00:00:00 2001 From: Michael Pasquale Date: Mon, 24 Aug 2026 12:07:23 -0700 Subject: [PATCH 5/5] textproto: refactor nested boundary test to share nestedFoo1/nestedFoo5 variables Co-Authored-By: Claude Sonnet 4.6 --- textproto/multipart_test.go | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/textproto/multipart_test.go b/textproto/multipart_test.go index 28a6be8..2e25c3c 100644 --- a/textproto/multipart_test.go +++ b/textproto/multipart_test.go @@ -965,18 +965,22 @@ func TestMatchAfterPrefix(t *testing.T) { func TestNestedMultipartHyphenBoundaries(t *testing.T) { crlf := func(s string) string { return strings.Replace(s, "\n", "\r\n", -1) } - input := crlf("--foo\n" + - "Content-Type: multipart/alternative; boundary=\"foo-5\"\n" + + nestedFoo1 := "--foo-1\n" + + "Content-Type: text/html\n" + "\n" + - "--foo-5\n" + + "hello\n" + + "--foo-1--" + + nestedFoo5 := "--foo-5\n" + "Content-Type: multipart/related; boundary=\"foo-1\"\n" + "\n" + - "--foo-1\n" + - "Content-Type: text/html\n" + + nestedFoo1 + "\n" + + "--foo-5--" + + input := crlf("--foo\n" + + "Content-Type: multipart/alternative; boundary=\"foo-5\"\n" + "\n" + - "hello\n" + - "--foo-1--\n" + - "--foo-5--\n" + + nestedFoo5 + "\n" + "--foo--\n") levels := []struct { @@ -987,24 +991,12 @@ func TestNestedMultipartHyphenBoundaries(t *testing.T) { { boundary: "foo", wantContentType: `multipart/alternative; boundary="foo-5"`, - wantBody: crlf("--foo-5\n" + - "Content-Type: multipart/related; boundary=\"foo-1\"\n" + - "\n" + - "--foo-1\n" + - "Content-Type: text/html\n" + - "\n" + - "hello\n" + - "--foo-1--\n" + - "--foo-5--"), + wantBody: crlf(nestedFoo5), }, { boundary: "foo-5", wantContentType: `multipart/related; boundary="foo-1"`, - wantBody: crlf("--foo-1\n" + - "Content-Type: text/html\n" + - "\n" + - "hello\n" + - "--foo-1--"), + wantBody: crlf(nestedFoo1), }, { boundary: "foo-1",