From e7c8e50895bbd86ab77a8cece343c4c926bcafec Mon Sep 17 00:00:00 2001 From: barry3406 Date: Thu, 9 Apr 2026 06:10:21 -0700 Subject: [PATCH 1/6] fix: move Keys read inside RLock in Context.Copy() to prevent data race Copy() reads c.Keys before acquiring the read lock, then clones the stale reference under the lock. If another goroutine calls Set() between the read and the lock acquisition, the cKeys variable may reference a map being concurrently modified. This is particularly dangerous on the first Set() call where c.Keys transitions from nil to a new map allocation. --- context.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/context.go b/context.go index 5174033e..34a6e7ca 100644 --- a/context.go +++ b/context.go @@ -132,9 +132,8 @@ func (c *Context) Copy() *Context { cp.handlers = nil cp.fullPath = c.fullPath - cKeys := c.Keys c.mu.RLock() - cp.Keys = maps.Clone(cKeys) + cp.Keys = maps.Clone(c.Keys) c.mu.RUnlock() cParams := c.Params From cf3be80b0e1625316e9b259dc7628b3acf22d047 Mon Sep 17 00:00:00 2001 From: barry3406 Date: Thu, 9 Apr 2026 06:10:36 -0700 Subject: [PATCH 2/6] fix: encode supplementary Unicode as surrogate pairs in AsciiJSON Characters above U+FFFF (emoji, math symbols, etc.) were escaped as \u1f600 (5+ hex digits) which is invalid JSON per RFC 8259. Strict JSON parsers reject this output. Per the spec, supplementary plane characters must be encoded as UTF-16 surrogate pairs (e.g. U+1F600 becomes \uD83D\uDE00). --- render/json.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/render/json.go b/render/json.go index 2f98676c..f2ccaf8c 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)) From 08e51b48be47e705be3e8ae445cdbdd46418870d Mon Sep 17 00:00:00 2001 From: barry3406 Date: Thu, 9 Apr 2026 06:10:47 -0700 Subject: [PATCH 3/6] fix: sanitize Proxy-Authorization header in recovery panic logs secureRequestDump only masks the Authorization header but not Proxy-Authorization, which also carries credentials (used by gin's own BasicAuthForProxy middleware). When a panic occurs behind proxy auth, credentials are logged in plaintext. --- recovery.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/recovery.go b/recovery.go index bbf1d565..802035a9 100644 --- a/recovery.go +++ b/recovery.go @@ -99,8 +99,8 @@ func secureRequestDump(r *http.Request) string { httpRequest, _ := httputil.DumpRequest(r, false) lines := strings.Split(bytesconv.BytesToString(httpRequest), "\r\n") for i, line := range lines { - if strings.HasPrefix(line, "Authorization:") { - lines[i] = "Authorization: *" + if strings.HasPrefix(line, "Authorization:") || strings.HasPrefix(line, "Proxy-Authorization:") { + lines[i] = strings.SplitN(line, ":", 2)[0] + ": *" } } return strings.Join(lines, "\r\n") From 4ce483f0f4b8c0b4baa8f195c4ed84bddca558c8 Mon Sep 17 00:00:00 2001 From: barry3406 Date: Thu, 9 Apr 2026 06:35:52 -0700 Subject: [PATCH 4/6] test: add tests for supplementary Unicode and Proxy-Authorization - Add TestRenderAsciiJSONSupplementaryUnicode to verify emoji (U+1F600) is encoded as UTF-16 surrogate pair (\uD83D\uDE00) - Add Proxy-Authorization test case to TestSecureRequestDump to verify credentials are sanitized in recovery panic logs --- recovery_test.go | 10 ++++++++++ render/render_test.go | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/recovery_test.go b/recovery_test.go index 028c4ad6..8749daba 100644 --- a/recovery_test.go +++ b/recovery_test.go @@ -293,6 +293,16 @@ func TestSecureRequestDump(t *testing.T) { 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: "No Authorization header", req: func() *http.Request { diff --git a/render/render_test.go b/render/render_test.go index f63878b9..85dca068 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -261,6 +261,16 @@ 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 + assert.Equal(t, "{\"emoji\":\"\\ud83d\\ude00\"}", w.Body.String()) +} + func TestRenderAsciiJSONFail(t *testing.T) { w := httptest.NewRecorder() data := make(chan int) From 473f9ddf556f8b75d99427e1870f4a6afaf0164c Mon Sep 17 00:00:00 2001 From: barry3406 Date: Thu, 9 Apr 2026 06:47:54 -0700 Subject: [PATCH 5/6] test: fix testifylint encoded-compare warning in AsciiJSON test --- render/render_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/render/render_test.go b/render/render_test.go index 85dca068..c234d189 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -267,8 +267,9 @@ func TestRenderAsciiJSONSupplementaryUnicode(t *testing.T) { err := (AsciiJSON{data}).Render(w) require.NoError(t, err) - // U+1F600 must be encoded as UTF-16 surrogate pair per RFC 8259 - assert.Equal(t, "{\"emoji\":\"\\ud83d\\ude00\"}", w.Body.String()) + // 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) { From 3f1b179fdc1edcc4b8f2232010322266e87bb98d Mon Sep 17 00:00:00 2001 From: barry3406 Date: Thu, 16 Apr 2026 11:22:28 -0700 Subject: [PATCH 6/6] address review feedback from Nurysso - context.go: guard maps.Clone with explicit nil check for c.Keys - recovery.go: match Authorization/Proxy-Authorization case-insensitively with strings.EqualFold and use simple hardcoded masked replacements instead of SplitN for readability --- context.go | 4 +++- recovery.go | 20 +++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/context.go b/context.go index 34a6e7ca..8757a32a 100644 --- a/context.go +++ b/context.go @@ -133,7 +133,9 @@ func (c *Context) Copy() *Context { cp.fullPath = c.fullPath c.mu.RLock() - cp.Keys = maps.Clone(c.Keys) + 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 802035a9..23492464 100644 --- a/recovery.go +++ b/recovery.go @@ -91,21 +91,31 @@ 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") for i, line := range lines { - if strings.HasPrefix(line, "Authorization:") || strings.HasPrefix(line, "Proxy-Authorization:") { - lines[i] = strings.SplitN(line, ":", 2)[0] + ": *" + switch { + case hasHeaderPrefixFold(line, "Authorization:"): + lines[i] = "Authorization: *" + case hasHeaderPrefixFold(line, "Proxy-Authorization:"): + lines[i] = "Proxy-Authorization: *" } } return strings.Join(lines, "\r\n") } +// hasHeaderPrefixFold reports whether line begins with prefix, ignoring ASCII case. +func hasHeaderPrefixFold(line, prefix string) bool { + return len(line) >= len(prefix) && strings.EqualFold(line[:len(prefix)], prefix) +} + func defaultHandleRecovery(c *Context, _ any) { c.AbortWithStatus(http.StatusInternalServerError) }