From 6942b48b72e101460329609fc7a0f97502445377 Mon Sep 17 00:00:00 2001 From: ankit-songara Date: Fri, 17 Jul 2026 00:56:03 +0530 Subject: [PATCH 1/2] fix(render): encode non-BMP runes as UTF-16 surrogate pairs in AsciiJSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AsciiJSON escaped every non-ASCII rune with fmt.Appendf(buf, "\u%04x", r), but %04x is a minimum width, not a fixed width. Runes outside the Basic Multilingual Plane (e.g. emoji) need 5+ hex digits, so a single malformed \uXXXXX token was written instead of a valid 4-digit JSON \u escape. The output stayed syntactically valid JSON, so this was easy to miss, but a decoder reads the first 4 hex digits as one character and treats the rest as literal text, corrupting the value silently. RFC 8259 §7 requires code points outside the BMP to be encoded as a UTF-16 surrogate pair. Use unicode/utf16.EncodeRune to produce the pair for those runes while keeping the existing single-escape path for BMP characters. Fixes #4688 --- render/json.go | 17 +++++++++++++---- render/render_test.go | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/render/json.go b/render/json.go index 2f98676c..d5ae54d0 100644 --- a/render/json.go +++ b/render/json.go @@ -10,6 +10,7 @@ import ( "html/template" "net/http" "unicode" + "unicode/utf16" "github.com/gin-gonic/gin/codec/json" "github.com/gin-gonic/gin/internal/bytesconv" @@ -160,14 +161,22 @@ 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 12 bytes: worst case is a \uXXXX\uXXXX surrogate pair for _, r := range bytesconv.BytesToString(ret) { - if r > unicode.MaxASCII { + switch { + case r <= unicode.MaxASCII: + buffer.WriteByte(byte(r)) + case r <= 0xFFFF: escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x", r) // Reuse escapeBuf buffer.Write(escapeBuf) - } else { - buffer.WriteByte(byte(r)) + default: + // Code points outside the Basic Multilingual Plane don't fit in a + // single 4-hex-digit \u escape; RFC 8259 §7 requires encoding them + // as a UTF-16 surrogate pair instead. + r1, r2 := utf16.EncodeRune(r) + escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x\\u%04x", r1, r2) + buffer.Write(escapeBuf) } } diff --git a/render/render_test.go b/render/render_test.go index f63878b9..745422d2 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -5,6 +5,7 @@ package render import ( + "encoding/json" "encoding/xml" "errors" "html/template" @@ -261,6 +262,23 @@ func TestRenderAsciiJSON(t *testing.T) { assert.Equal(t, "3.1415926", w2.Body.String()) } +func TestRenderAsciiJSONNonBMP(t *testing.T) { + w := httptest.NewRecorder() + data := map[string]any{"msg": "😀"} + + err := (AsciiJSON{data}).Render(w) + require.NoError(t, err) + + // U+1F600 is outside the Basic Multilingual Plane and must be encoded as + // a UTF-16 surrogate pair (RFC 8259 §7), not a single \u escape with 5+ + // hex digits. + assert.Equal(t, "{\"msg\":\"\\ud83d\\ude00\"}", w.Body.String()) + + var decoded map[string]string + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &decoded)) + assert.Equal(t, "😀", decoded["msg"]) +} + func TestRenderAsciiJSONFail(t *testing.T) { w := httptest.NewRecorder() data := make(chan int) From 541b0eb5787065696126069a9ca56e641cbc2d55 Mon Sep 17 00:00:00 2001 From: ankit-songara Date: Fri, 17 Jul 2026 10:19:42 +0530 Subject: [PATCH 2/2] fix(render): use assert.JSONEq for AsciiJSON non-BMP test golangci-lint's testifylint rule flags comparing two JSON strings with assert.Equal, so switch to assert.JSONEq to satisfy CI. --- render/render_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/render/render_test.go b/render/render_test.go index 745422d2..4cfe3925 100644 --- a/render/render_test.go +++ b/render/render_test.go @@ -272,7 +272,7 @@ func TestRenderAsciiJSONNonBMP(t *testing.T) { // U+1F600 is outside the Basic Multilingual Plane and must be encoded as // a UTF-16 surrogate pair (RFC 8259 §7), not a single \u escape with 5+ // hex digits. - assert.Equal(t, "{\"msg\":\"\\ud83d\\ude00\"}", w.Body.String()) + assert.JSONEq(t, "{\"msg\":\"\\ud83d\\ude00\"}", w.Body.String()) var decoded map[string]string require.NoError(t, json.Unmarshal(w.Body.Bytes(), &decoded))