From 4bc93cabcff5d8f138e63f78db704b3b2482ee10 Mon Sep 17 00:00:00 2001 From: "a.bolonin" Date: Sat, 31 Oct 2026 10:01:16 +0300 Subject: [PATCH 1/2] perf: fuse uncommon header name conversion --- internal/phpheaders/phpheaders.go | 28 ++++++++++++++++++- internal/phpheaders/phpheaders_test.go | 38 ++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/internal/phpheaders/phpheaders.go b/internal/phpheaders/phpheaders.go index 711d8abc55..fe79171bae 100644 --- a/internal/phpheaders/phpheaders.go +++ b/internal/phpheaders/phpheaders.go @@ -4,6 +4,7 @@ import "C" import ( "context" "strings" + "unicode/utf8" "github.com/maypok86/otter/v2" ) @@ -123,7 +124,7 @@ var ( headerKeyCache = otter.Must(&otter.Options[string, string]{MaximumSize: 256}) headerNameReplacer = strings.NewReplacer("-", "_") loader = otter.LoaderFunc[string, string](func(_ context.Context, key string) (string, error) { - return "HTTP_" + headerNameReplacer.Replace(strings.ToUpper(key)) + "\x00", nil + return headerName(key), nil }) ) @@ -136,3 +137,28 @@ func GetUnCommonHeader(key string) string { return phpHeaderKey } + +// headerName converts a header name to its NUL-terminated PHP variable name. +func headerName(key string) string { + for i := range len(key) { + if key[i] >= utf8.RuneSelf { + return "HTTP_" + headerNameReplacer.Replace(strings.ToUpper(key)) + "\x00" + } + } + + var b strings.Builder + b.Grow(len(key) + len("HTTP_") + 1) + b.WriteString("HTTP_") + for i := range len(key) { + c := key[i] + switch { + case c >= 'a' && c <= 'z': + c -= 'a' - 'A' + case c == '-': + c = '_' + } + b.WriteByte(c) + } + b.WriteByte(0) + return b.String() +} diff --git a/internal/phpheaders/phpheaders_test.go b/internal/phpheaders/phpheaders_test.go index 792eeb4ee3..4958c1ac16 100644 --- a/internal/phpheaders/phpheaders_test.go +++ b/internal/phpheaders/phpheaders_test.go @@ -47,3 +47,41 @@ func TestHeaderWithSpaceIsRejectedByNetHTTP(t *testing.T) { assert.Contains(t, string(resp), "400 Bad Request") assert.Contains(t, string(resp), "invalid header name") } + +func TestHeaderName(t *testing.T) { + for _, key := range []string{ + "", "X-Custom-Trace-Id", "X-CUSTOM-TRACE-ID", "x-custom-trace-id", + "X-With_Underscore.And Space", "X-Ä-ß-Trace", "X-ı-ſ", "X-\xff-Trace", "X-\x00-Trace", + } { + t.Run(key, func(t *testing.T) { + want := "HTTP_" + strings.NewReplacer("-", "_").Replace(strings.ToUpper(key)) + "\x00" + assert.Equal(t, want, headerName(key)) + }) + } +} + +func FuzzHeaderName(f *testing.F) { + for _, key := range []string{"", "X-Custom-Trace-Id", "X-Ä-ß-Trace", "X-\xff-Trace", "X-\x00-Trace"} { + f.Add(key) + } + replacer := strings.NewReplacer("-", "_") + f.Fuzz(func(t *testing.T, key string) { + want := "HTTP_" + replacer.Replace(strings.ToUpper(key)) + "\x00" + if got := headerName(key); got != want { + t.Fatalf("headerName(%q) = %q, want %q", key, got, want) + } + }) +} + +var headerNameResult string + +func BenchmarkHeaderName(b *testing.B) { + for _, key := range []string{"X-Custom-Trace-Id", "X-CUSTOM-TRACE-ID", "x-custom-trace-id", "X-Ä-ß-Trace", ""} { + b.Run(key, func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + headerNameResult = headerName(key) + } + }) + } +} From 8cf8a3e8be262f1936cfa430e2c2e63685b4bb79 Mon Sep 17 00:00:00 2001 From: "a.bolonin" Date: Sat, 31 Oct 2026 13:42:03 +0300 Subject: [PATCH 2/2] perf: rely on net/http header name validation --- internal/phpheaders/phpheaders.go | 15 +++------ internal/phpheaders/phpheaders_test.go | 42 +++++++++++++++----------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/internal/phpheaders/phpheaders.go b/internal/phpheaders/phpheaders.go index fe79171bae..3f227620ee 100644 --- a/internal/phpheaders/phpheaders.go +++ b/internal/phpheaders/phpheaders.go @@ -4,7 +4,6 @@ import "C" import ( "context" "strings" - "unicode/utf8" "github.com/maypok86/otter/v2" ) @@ -121,9 +120,8 @@ var CommonRequestHeaders = map[string]string{ // Cache up to 256 uncommon headers // This is ~2.5x faster than converting the header each time var ( - headerKeyCache = otter.Must(&otter.Options[string, string]{MaximumSize: 256}) - headerNameReplacer = strings.NewReplacer("-", "_") - loader = otter.LoaderFunc[string, string](func(_ context.Context, key string) (string, error) { + headerKeyCache = otter.Must(&otter.Options[string, string]{MaximumSize: 256}) + loader = otter.LoaderFunc[string, string](func(_ context.Context, key string) (string, error) { return headerName(key), nil }) ) @@ -138,14 +136,9 @@ func GetUnCommonHeader(key string) string { return phpHeaderKey } -// headerName converts a header name to its NUL-terminated PHP variable name. +// headerName converts a validated ASCII header name to its NUL-terminated PHP variable name. +// net/http rejects invalid header names before they reach the handler. func headerName(key string) string { - for i := range len(key) { - if key[i] >= utf8.RuneSelf { - return "HTTP_" + headerNameReplacer.Replace(strings.ToUpper(key)) + "\x00" - } - } - var b strings.Builder b.Grow(len(key) + len("HTTP_") + 1) b.WriteString("HTTP_") diff --git a/internal/phpheaders/phpheaders_test.go b/internal/phpheaders/phpheaders_test.go index 4958c1ac16..f89a11d4a9 100644 --- a/internal/phpheaders/phpheaders_test.go +++ b/internal/phpheaders/phpheaders_test.go @@ -1,7 +1,7 @@ package phpheaders import ( - "io" + "bufio" "net" "net/http" "net/http/httptest" @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "golang.org/x/net/http/httpguts" ) func TestAllCommonHeadersAreCorrect(t *testing.T) { @@ -26,32 +27,34 @@ func TestAllCommonHeadersAreCorrect(t *testing.T) { } } -// Go's net/http server rejects header names containing spaces with a 400 response -// before the request reaches the handler, so headerNameReplacer does not need to -// translate spaces to underscores. -func TestHeaderWithSpaceIsRejectedByNetHTTP(t *testing.T) { +// Header names arriving over HTTP must be valid ASCII before conversion to PHP variables. +func TestInvalidHeaderNamesAreRejectedByNetHTTP(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("handler should not be reached, got headers: %v", r.Header) })) t.Cleanup(ts.Close) - conn, err := net.Dial("tcp", strings.TrimPrefix(ts.URL, "http://")) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, conn.Close()) }) + for _, name := range []string{"Bad Header", "X-Ä-Trace", "X-ı-ſ", "X-\xff-Trace", "X-\x00-Trace"} { + t.Run(name, func(t *testing.T) { + conn, err := net.Dial("tcp", strings.TrimPrefix(ts.URL, "http://")) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) - _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: localhost\r\nBad Header: x\r\nConnection: close\r\n\r\n")) - require.NoError(t, err) + _, err = conn.Write([]byte("GET / HTTP/1.1\r\nHost: localhost\r\n" + name + ": x\r\nConnection: close\r\n\r\n")) + require.NoError(t, err) - resp, err := io.ReadAll(conn) - require.NoError(t, err) - assert.Contains(t, string(resp), "400 Bad Request") - assert.Contains(t, string(resp), "invalid header name") + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, resp.Body.Close()) }) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + }) + } } func TestHeaderName(t *testing.T) { for _, key := range []string{ - "", "X-Custom-Trace-Id", "X-CUSTOM-TRACE-ID", "x-custom-trace-id", - "X-With_Underscore.And Space", "X-Ä-ß-Trace", "X-ı-ſ", "X-\xff-Trace", "X-\x00-Trace", + "X-Custom-Trace-Id", "X-CUSTOM-TRACE-ID", "x-custom-trace-id", + "X-With_Underscore.And.Dot", "!#$%&'*+-.^_`|~0123456789", } { t.Run(key, func(t *testing.T) { want := "HTTP_" + strings.NewReplacer("-", "_").Replace(strings.ToUpper(key)) + "\x00" @@ -61,11 +64,14 @@ func TestHeaderName(t *testing.T) { } func FuzzHeaderName(f *testing.F) { - for _, key := range []string{"", "X-Custom-Trace-Id", "X-Ä-ß-Trace", "X-\xff-Trace", "X-\x00-Trace"} { + for _, key := range []string{"X-Custom-Trace-Id", "x-custom-trace-id", "!#$%&'*+-.^_`|~0123456789"} { f.Add(key) } replacer := strings.NewReplacer("-", "_") f.Fuzz(func(t *testing.T, key string) { + if !httpguts.ValidHeaderFieldName(key) { + return + } want := "HTTP_" + replacer.Replace(strings.ToUpper(key)) + "\x00" if got := headerName(key); got != want { t.Fatalf("headerName(%q) = %q, want %q", key, got, want) @@ -76,7 +82,7 @@ func FuzzHeaderName(f *testing.F) { var headerNameResult string func BenchmarkHeaderName(b *testing.B) { - for _, key := range []string{"X-Custom-Trace-Id", "X-CUSTOM-TRACE-ID", "x-custom-trace-id", "X-Ä-ß-Trace", ""} { + for _, key := range []string{"X-Custom-Trace-Id", "X-CUSTOM-TRACE-ID", "x-custom-trace-id"} { b.Run(key, func(b *testing.B) { b.ReportAllocs() for b.Loop() {