Repository: valyala/fasthttp
Pull request #1685 (2023-12-31 04:09:58)

feat: add function to parse HTTP header parameters
The implementation is based on RFC-9110 5.6.6.

https://www.rfc-editor.org/rfc/rfc9110#name-parameters

The primary use case for parsing header parameters is for content negotiation, including multipart type boundaries.

The functor passed to VisitHeaderParams has a bool return value to allow the user to stop processing parameters early.
Examples of this being useful are to find a specific parameter, stop parsing after the q value, or to put a hard limit on the number of parameters a client can include.

I had originally written this for a different project using fasthttp, but I figured it was generic enough that it may warrant being merged in for others to use as well.  If not, no feelings will be hurt.

The implementation of validHeaderFieldByte is a little silly looking, but it's as fast as I could get it.
The Go standard library has its own implementation here, which is a bit slower: https://github.com/golang/go/blob/master/src/net/textproto/reader.go#L663
If the table solution is too arcane, we can copy the standard library instead.

Side note: VisitHeaderParams could also be used in MultipartFormBoundary to make fewer assumptions about how the client formatted their Content-Type header, but it would slow the function down quite a bit.
On my machine, it's 20 ns/op today vs 40 ns/op using VisitHeaderParams.

nickajacks1: I see that existing fuzz tests use fuzzit, but given that the CI only builds on 1.18 and above and that there are no docs mentioning fuzzit, I just created a Fuzz test in header_test.go instead.

Ran a short 14 min fuzz:
`fuzz: elapsed: 14m5s, execs: 27008124 (33905/sec), new interesting: 34 (total: 50)`

Patch:
diff --git a/header.go b/header.go
index 51be74a..16d6a6c 100644
--- a/header.go
+++ b/header.go
@@ -545,6 +545,104 @@ func (h *ResponseHeader) AddTrailerBytes(trailer []byte) error {
 	return err
 }
 
+// validHeaderFieldByte returns true if c is a valid tchar as defined
+// by section 5.6.2 of [RFC9110].
+func validHeaderFieldByte(c byte) bool {
+	return c < 128 && tcharTable[c]
+}
+
+//	 tchar  = "!" / "#" / "$" / "%" / "&" / "'" / "*"
+//			   / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
+//			   / DIGIT / ALPHA
+//
+// See: https://www.rfc-editor.org/rfc/rfc9110#tokens
+var tcharTable = [128]bool{
+	'!': true, '#': true, '$': true, '%': true, '&': true, '\'': true, '*': true, '+': true,
+	'-': true, '.': true, '^': true, '_': true, '`': true, '|': true, '~': true,
+	'0': true, '1': true, '2': true, '3': true, '4': true, '5': true, '6': true, '7': true, '8': true, '9': true,
+	'A': true, 'B': true, 'C': true, 'D': true, 'E': true, 'F': true, 'G': true,
+	'H': true, 'I': true, 'J': true, 'K': true, 'L': true, 'M': true, 'N': true,
+	'O': true, 'P': true, 'Q': true, 'R': true, 'S': true, 'T': true, 'U': true,
+	'V': true, 'W': true, 'X': true, 'Y': true, 'Z': true,
+	'a': true, 'b': true, 'c': true, 'd': true, 'e': true, 'f': true, 'g': true,
+	'h': true, 'i': true, 'j': true, 'k': true, 'l': true, 'm': true, 'n': true,
+	'o': true, 'p': true, 'q': true, 'r': true, 's': true, 't': true, 'u': true,
+	'v': true, 'w': true, 'x': true, 'y': true, 'z': true,
+}
+
+// VisitHeaderParams calls f for each parameter in the given header bytes.
+// It stops processing when f returns false or an invalid parameter is found.
+// Parameter values may be quoted, in which case \ is treated as an escape
+// character, and the value is unquoted before being passed to value.
+// See: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.6
+//
+// f must not retain references to key and/or value after returning.
+// Copy key and/or value contents before returning if you need retaining them.
+func VisitHeaderParams(b []byte, f func(key, value []byte) bool) {
+	for len(b) > 0 {
+		idxSemi := 0
+		for idxSemi < len(b) && b[idxSemi] != ';' {
+			idxSemi++
+		}
+		if idxSemi >= len(b) {
+			return
+		}
+		b = b[idxSemi+1:]
+		for len(b) > 0 && b[0] == ' ' {
+			b = b[1:]
+		}
+
+		n := 0
+		if len(b) == 0 || !validHeaderFieldByte(b[n]) {
+			return
+		}
+		n++
+		for n < len(b) && validHeaderFieldByte(b[n]) {
+			n++
+		}
+
+		if n >= len(b)-1 || b[n] != '=' {
+			return
+		}
+		param := b[:n]
+		n++
+
+		switch {
+		case validHeaderFieldByte(b[n]):
+			m := n
+			n++
+			for n < len(b) && validHeaderFieldByte(b[n]) {
+				n++
+			}
+			if !f(param, b[m:n]) {
+				return
+			}
+		case b[n] == '"':
+			foundEndQuote := false
+			escaping := false
+			n++
+			m := n
+			for ; n < len(b); n++ {
+				if b[n] == '"' && !escaping {
+					foundEndQuote = true
+					break
+				}
+				escaping = (b[n] == '\\' && !escaping)
+			}
+			if !foundEndQuote {
+				return
+			}
+			if !f(param, b[m:n]) {
+				return
+			}
+			n++
+		default:
+			return
+		}
+		b = b[n:]
+	}
+}
+
 // MultipartFormBoundary returns boundary part
 // from 'multipart/form-data; boundary=...' Content-Type.
 func (h *RequestHeader) MultipartFormBoundary() []byte {


Test patch:
diff --git a/header_test.go b/header_test.go
index 2893751..eaa9a50 100644
--- a/header_test.go
+++ b/header_test.go
@@ -1061,6 +1061,75 @@ func testRequestHeaderHasAcceptEncoding(t *testing.T, ae, v string, resultExpect
 	}
 }
 
+func TestVisitHeaderParams(t *testing.T) {
+	t.Parallel()
+	testVisitHeaderParams(t, "text/plain;charset=utf-8;q=0.39", [][2]string{{"charset", "utf-8"}, {"q", "0.39"}})
+	testVisitHeaderParams(t, "text/plain;   foo=bar   ;", [][2]string{{"foo", "bar"}})
+	testVisitHeaderParams(t, `text/plain;      foo="bar";   `, [][2]string{{"foo", "bar"}})
+	testVisitHeaderParams(t, `text/plain; foo="text/plain,text/html;charset=\"utf-8\""`, [][2]string{{"foo", `text/plain,text/html;charset=\"utf-8\"`}})
+	testVisitHeaderParams(t, "text/plain foo=bar", [][2]string{})
+	testVisitHeaderParams(t, "text/plain;", [][2]string{})
+	testVisitHeaderParams(t, "text/plain; ", [][2]string{})
+	testVisitHeaderParams(t, "text/plain; foo", [][2]string{})
+	testVisitHeaderParams(t, "text/plain; foo=", [][2]string{})
+	testVisitHeaderParams(t, "text/plain; =bar", [][2]string{})
+	testVisitHeaderParams(t, "text/plain; foo = bar", [][2]string{})
+	testVisitHeaderParams(t, `text/plain; foo="bar`, [][2]string{})
+	testVisitHeaderParams(t, "text/plain;;foo=bar", [][2]string{})
+
+	parsed := make([][2]string, 0)
+	VisitHeaderParams([]byte(`text/plain; foo=bar; charset=utf-8`), func(key, value []byte) bool {
+		parsed = append(parsed, [2]string{string(key), string(value)})
+		return !bytes.Equal(key, []byte("foo"))
+	})
+
+	if len(parsed) != 1 {
+		t.Fatalf("expected 1 HTTP parameter, parsed %v", len(parsed))
+	}
+
+	if parsed[0] != [2]string{"foo", "bar"} {
+		t.Fatalf("unexpected parameter %v=%v. Expecting foo=bar", parsed[0][0], parsed[0][1])
+	}
+}
+
+func testVisitHeaderParams(t *testing.T, header string, expectedParams [][2]string) {
+	parsed := make([][2]string, 0)
+	VisitHeaderParams([]byte(header), func(key, value []byte) bool {
+		parsed = append(parsed, [2]string{string(key), string(value)})
+		return true
+	})
+
+	if len(parsed) != len(expectedParams) {
+		t.Fatalf("expected %v HTTP parameters, parsed %v", len(expectedParams), len(parsed))
+	}
+
+	for i := range expectedParams {
+		if expectedParams[i] != parsed[i] {
+			t.Fatalf("unexpected parameter %v=%v. Expecting %v=%v", parsed[i][0], parsed[i][1], expectedParams[i][0], expectedParams[i][1])
+		}
+	}
+}
+
+func FuzzVisitHeaderParams(f *testing.F) {
+	inputs := []string{
+		`application/json; v=1; foo=bar; q=0.938; param=param; param="big fox"; q=0.43`,
+		`*/*`,
+		`\\`,
+		`text/plain; foo="\\\"\'\\''\'"`,
+	}
+	for _, input := range inputs {
+		f.Add([]byte(input))
+	}
+	f.Fuzz(func(t *testing.T, header []byte) {
+		VisitHeaderParams(header, func(key, value []byte) bool {
+			if len(key) == 0 {
+				t.Errorf("Unexpected length zero parameter, failed input was: %s", header)
+			}
+			return true
+		})
+	})
+}
+
 func TestRequestMultipartFormBoundary(t *testing.T) {
 	t.Parallel()
 
diff --git a/header_timing_test.go b/header_timing_test.go
index 66698c4..90f65ae 100644
--- a/header_timing_test.go
+++ b/header_timing_test.go
@@ -180,6 +180,19 @@ func benchmarkNormalizeHeaderKey(b *testing.B, src []byte) {
 	})
 }
 
+func BenchmarkVisitHeaderParams(b *testing.B) {
+	var h RequestHeader
+	h.SetBytesKV(strContentType, []byte(`text/plain  ;  foo=bar  ;   param2="dquote is: [\"], ok?" ; version=1; q=0.324  `))
+
+	header := h.ContentType()
+	b.ReportAllocs()
+	b.ResetTimer()
+
+	for n := 0; n < b.N; n++ {
+		VisitHeaderParams(header, func(key, value []byte) bool { return true })
+	}
+}
+
 func BenchmarkRemoveNewLines(b *testing.B) {
 	type testcase struct {
 		value         string