Repository: jhillyerd/enmime (go)

Issue:
TestReadHeader/equals_in_name fails on Go1.20
What I did:

```
$ go version
go version go1.20rc3 linux/amd64
```

```
go test -run TestReadHeader/equals_in_name
```

What I expected: PASS

What I got:

```
--- FAIL: TestReadHeader (0.00s)
    --- FAIL: TestReadHeader/equals_in_name (0.00s)
        header_test.go:325: malformed MIME header line: name=value:text
FAIL
```

Release or branch I am using: master

Pull request:
fix: Reject invalid MIME header name characters to support Go 1.20
Fixes #269


Patch:
diff --git a/header.go b/header.go
index 633fc8a..c97d6e0 100644
--- a/header.go
+++ b/header.go
@@ -108,9 +108,9 @@ func ParseAddressList(list string) ([]*mail.Address, error) {
 // ParseMediaType is a more tolerant implementation of Go's mime.ParseMediaType function.
 //
 // Tolerances accounted for:
-//   * Missing ';' between content-type and media parameters
-//   * Repeating media parameters
-//   * Unquoted values in media parameters containing 'tspecials' characters
+//   - Missing ';' between content-type and media parameters
+//   - Repeating media parameters
+//   - Unquoted values in media parameters containing 'tspecials' characters
 func ParseMediaType(ctype string) (mtype string, params map[string]string, invalidParams []string,
 	err error) {
 	// Export of internal function.
@@ -124,6 +124,7 @@ func readHeader(r *bufio.Reader, p *Part) (textproto.MIMEHeader, error) {
 	buf := &bytes.Buffer{}
 	tp := textproto.NewReader(r)
 	firstHeader := true
+line:
 	for {
 		// Pull out each line of the headers as a temporary slice s
 		s, err := tp.ReadLineBytes()
@@ -146,18 +147,29 @@ func readHeader(r *bufio.Reader, p *Part) (textproto.MIMEHeader, error) {
 			continue
 		}
 		if firstColon > 0 {
-			// Contains a colon, treat as a new header line
-			if !firstHeader {
-				// New Header line, end the previous
-				buf.Write([]byte{'\r', '\n'})
-			}
-
 			// Behavior change in net/textproto package in Golang 1.12.10 and 1.13.1:
 			// A space preceding the first colon in a header line is no longer handled
 			// automatically due to CVE-2019-16276 which takes advantage of this
 			// particular violation of RFC-7230 to exploit HTTP/1.1
 			if bytes.Contains(s[:firstColon+1], []byte{' ', ':'}) {
 				s = bytes.Replace(s, []byte{' ', ':'}, []byte{':'}, 1)
+				firstColon = bytes.IndexByte(s, ':')
+			}
+
+			// Behavior change in net/textproto package in Golang 1.20: invalid characters
+			// in header keys are no longer allowed; https://github.com/golang/go/issues/53188
+			for _, c := range s[:firstColon] {
+				if c != ' ' && !validHeaderFieldByte(c) {
+					p.addError(
+						ErrorMalformedHeader, "Header name %q contains invalid character %q", s, c)
+					continue line
+				}
+			}
+
+			// Contains a colon, treat as a new header line
+			if !firstHeader {
+				// New Header line, end the previous
+				buf.Write([]byte{'\r', '\n'})
 			}
 
 			s = textproto.TrimBytes(s)
@@ -240,3 +252,32 @@ func quotedDisplayName(s string) string {
 func whiteSpaceRune(r rune) bool {
 	return r == ' ' || r == '\t' || r == '\r' || r == '\n'
 }
+
+// Mirror of func in net/textproto/reader.go; from go 1.20.0-rc.2
+func validHeaderFieldByte(c byte) bool {
+	// mask is a 128-bit bitmap with 1s for allowed bytes,
+	// so that the byte c can be tested with a shift and an and.
+	// If c >= 128, then 1<<c and 1<<(c-64) will both be zero,
+	// and this function will return false.
+	const mask = 0 |
+		(1<<(10)-1)<<'0' |
+		(1<<(26)-1)<<'a' |
+		(1<<(26)-1)<<'A' |
+		1<<'!' |
+		1<<'#' |
+		1<<'$' |
+		1<<'%' |
+		1<<'&' |
+		1<<'\'' |
+		1<<'*' |
+		1<<'+' |
+		1<<'-' |
+		1<<'.' |
+		1<<'^' |
+		1<<'_' |
+		1<<'`' |
+		1<<'|' |
+		1<<'~'
+	return ((uint64(1)<<c)&(mask&(1<<64-1)) |
+		(uint64(1)<<(c-64))&(mask>>64)) != 0
+}


Test patch:
diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml
index bae05d4..d196c31 100644
--- a/.github/workflows/build-and-test.yml
+++ b/.github/workflows/build-and-test.yml
@@ -8,15 +8,15 @@ jobs:
       matrix:
         go:
           - 1.16.x
-          - 1.17.x
           - 1.18.x
+          - 1.20.0-rc.2
     name: Go ${{ matrix.go }} build
     steps:
-      - uses: actions/checkout@v2
+      - uses: actions/checkout@v3
         with:
           fetch-depth: 0
       - name: Setup Go
-        uses: actions/setup-go@v2
+        uses: actions/setup-go@v3
         with:
           go-version: ${{ matrix.go }}
       - name: Build and Test
diff --git a/header_test.go b/header_test.go
index 48d6926..d143f35 100644
--- a/header_test.go
+++ b/header_test.go
@@ -217,8 +217,6 @@ func TestReadHeader(t *testing.T) {
 		{
 			label:   "missing name",
 			input:   ": line1=foo\r\n",
-			hname:   "",
-			want:    "",
 			correct: false,
 		},
 		{
@@ -245,12 +243,17 @@ func TestReadHeader(t *testing.T) {
 			correct: true,
 		},
 		{
-			label:   "equals in name",
-			input:   "name=value:text\n",
-			hname:   "name=value",
+			label:   "permitted specials in name",
+			input:   "Begin!#$%&'*+-.^_`|~End: text\n",
+			hname:   "Begin!#$%&'*+-.^_`|~End",
 			want:    "text",
 			correct: true,
 		},
+		{
+			label:   "equals in name",
+			input:   "name=value:text\n",
+			correct: false,
+		},
 		{
 			label:   "no space before continuation",
 			input:   "X-Bad-Continuation: line1=foo;\nline2=bar\n",
@@ -354,6 +357,11 @@ func TestReadHeader(t *testing.T) {
 			gotErrs := len(p.Errors)
 			if gotErrs != wantErrs {
 				t.Errorf("Got %v p.Errors, want %v", gotErrs, wantErrs)
+				if gotErrs > 0 {
+					for _, e := range p.Errors {
+						t.Log(e)
+					}
+				}
 			}
 
 			// Check for extra headers by removing expected ones.