diff --git a/context.go b/context.go index 5174033eb3..8757a32ad8 100644 --- a/context.go +++ b/context.go @@ -132,9 +132,10 @@ func (c *Context) Copy() *Context { cp.handlers = nil cp.fullPath = c.fullPath - cKeys := c.Keys c.mu.RLock() - cp.Keys = maps.Clone(cKeys) + if c.Keys != nil { + cp.Keys = maps.Clone(c.Keys) + } c.mu.RUnlock() cParams := c.Params diff --git a/recovery.go b/recovery.go index bbf1d565bf..128654ace4 100644 --- a/recovery.go +++ b/recovery.go @@ -91,16 +91,25 @@ func CustomRecoveryWithWriter(out io.Writer, handle RecoveryFunc) HandlerFunc { } } -// secureRequestDump returns a sanitized HTTP request dump where the Authorization header, -// if present, is replaced with a masked value ("Authorization: *") to avoid leaking sensitive credentials. +// secureRequestDump returns a sanitized HTTP request dump where the Authorization +// and Proxy-Authorization headers, if present, are replaced with a masked value +// (e.g. "Authorization: *") to avoid leaking sensitive credentials. // -// Currently, only the Authorization header is sanitized. All other headers and request data remain unchanged. +// Header name matching is case-insensitive since HTTP headers are case-insensitive +// per RFC 9110. All other headers and request data remain unchanged. func secureRequestDump(r *http.Request) string { httpRequest, _ := httputil.DumpRequest(r, false) lines := strings.Split(bytesconv.BytesToString(httpRequest), "\r\n") + const ( + authPrefix = "Authorization:" + proxyPrefix = "Proxy-Authorization:" + ) for i, line := range lines { - if strings.HasPrefix(line, "Authorization:") { + switch { + case len(line) >= len(authPrefix) && strings.EqualFold(line[:len(authPrefix)], authPrefix): lines[i] = "Authorization: *" + case len(line) >= len(proxyPrefix) && strings.EqualFold(line[:len(proxyPrefix)], proxyPrefix): + lines[i] = "Proxy-Authorization: *" } } return strings.Join(lines, "\r\n") diff --git a/recovery_test.go b/recovery_test.go index 028c4ad6d6..151f2ccc8f 100644 --- a/recovery_test.go +++ b/recovery_test.go @@ -274,25 +274,67 @@ func TestSecureRequestDump(t *testing.T) { wantNotContain: "Bearer secret-token", }, { + // Bypass http.Header.Set canonicalization to put a lowercase + // header name on the wire and verify case-insensitive matching. name: "authorization header lowercase", req: func() *http.Request { r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) - r.Header.Set("authorization", "some-secret") + r.Header["authorization"] = []string{"some-secret"} return r }(), wantContains: "Authorization: *", wantNotContain: "some-secret", }, + { + name: "AUTHORIZATION header uppercase", + req: func() *http.Request { + r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + r.Header["AUTHORIZATION"] = []string{"UPPER-SECRET"} + return r + }(), + wantContains: "Authorization: *", + wantNotContain: "UPPER-SECRET", + }, { name: "Authorization header mixed case", req: func() *http.Request { r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) - r.Header.Set("AuThOrIzAtIoN", "token123") + r.Header["AuThOrIzAtIoN"] = []string{"token123"} return r }(), wantContains: "Authorization: *", wantNotContain: "token123", }, + { + name: "Proxy-Authorization header", + req: func() *http.Request { + r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + r.Header.Set("Proxy-Authorization", "Basic cHJveHk6c2VjcmV0") + return r + }(), + wantContains: "Proxy-Authorization: *", + wantNotContain: "Basic cHJveHk6c2VjcmV0", + }, + { + name: "proxy-authorization header lowercase", + req: func() *http.Request { + r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + r.Header["proxy-authorization"] = []string{"Basic bG93ZXI="} + return r + }(), + wantContains: "Proxy-Authorization: *", + wantNotContain: "Basic bG93ZXI=", + }, + { + name: "PROXY-AUTHORIZATION header uppercase", + req: func() *http.Request { + r, _ := http.NewRequest(http.MethodGet, "http://example.com", nil) + r.Header["PROXY-AUTHORIZATION"] = []string{"Basic VVBQRVI="} + return r + }(), + wantContains: "Proxy-Authorization: *", + wantNotContain: "Basic VVBQRVI=", + }, { name: "No Authorization header", req: func() *http.Request { diff --git a/render/json.go b/render/json.go index 2f98676cff..f2ccaf8c64 100644 --- a/render/json.go +++ b/render/json.go @@ -160,11 +160,19 @@ func (r AsciiJSON) Render(w http.ResponseWriter) error { } var buffer bytes.Buffer - escapeBuf := make([]byte, 0, 6) // Preallocate 6 bytes for Unicode escape sequences + escapeBuf := make([]byte, 0, 12) // Preallocate for surrogate pair escape sequences for _, r := range bytesconv.BytesToString(ret) { if r > unicode.MaxASCII { - escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x", r) // Reuse escapeBuf + if r > 0xFFFF { + // Supplementary plane: encode as UTF-16 surrogate pair per RFC 8259 + r -= 0x10000 + high := 0xD800 + (r>>10)&0x3FF + low := 0xDC00 + r&0x3FF + escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x\\u%04x", high, low) + } else { + escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x", r) + } buffer.Write(escapeBuf) } else { buffer.WriteByte(byte(r)) diff --git a/render/render_test.go b/render/render_test.go index f63878b966..c234d18958 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -261,6 +261,17 @@ func TestRenderAsciiJSON(t *testing.T) { assert.Equal(t, "3.1415926", w2.Body.String()) } +func TestRenderAsciiJSONSupplementaryUnicode(t *testing.T) { + w := httptest.NewRecorder() + data := map[string]string{"emoji": "😀"} + + err := (AsciiJSON{data}).Render(w) + require.NoError(t, err) + // U+1F600 must be encoded as UTF-16 surrogate pair per RFC 8259. + // Use Contains to verify the surrogate pair encoding in the raw output. + assert.Contains(t, w.Body.String(), `\ud83d\ude00`) +} + func TestRenderAsciiJSONFail(t *testing.T) { w := httptest.NewRecorder() data := make(chan int)