Skip to content

Commit 2643d11

Browse files
authored
Decode inbound lone-surrogate frames instead of dropping them (#77)
Parse decoded the quoted Data of inbound Input/Click/ContextMenu/Remove frames with strconv.Unquote, returning failure when it errored. The browser encodes that data with JSON.stringify, whose grammar is not a subset of strconv.Unquote's: a lone UTF-16 surrogate is emitted as the literal escape "\udXXX" (JSON.stringify does not throw on lone surrogates), which strconv.Unquote rejects. ReadLoop then silently discarded the whole frame, before the ToValidUTF8 sanitizer ran, losing a legitimate user event. Input values are UTF-16 DOMStrings and can hold lone surrogates (emoji split, IME/paste, programmatic set). Fall back to a JSON string decode (which maps a lone surrogate to U+FFFD) when strconv.Unquote fails, so the event is delivered sanitized rather than dropped. The message is rejected only if both decoders fail; the common and verbatim paths are unchanged. The fallback was chosen by benchmarking three decoders (strconv.Unquote only, JSON only, and Unquote-with-JSON-fallback): pure JSON regressed every inbound frame ~10x with 4-5 extra allocations, while the fallback matches strconv.Unquote on the common path and pays the JSON cost only on the rare surrogate case. The benchmark also exposed that inlining json.Unmarshal(&data) forced Parse's data local to escape to the heap on every call (a uniform +1 alloc on all frames, including the unquoted and Set/Call paths that never decode); moving the address-taken target into jsonUnquoteString keeps data on the stack. BenchmarkParse confirms the common, unquoted and verbatim paths are allocation-identical to before; only the previously-dropped surrogate frame costs more. Kept as a regression guard.
1 parent 40b9c2c commit 2643d11

3 files changed

Lines changed: 86 additions & 7 deletions

File tree

‎lib/wire/wsmsg.go‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package wire
22

33
import (
44
"bytes"
5+
"encoding/json"
56
"html"
67
"strconv"
78
"strings"
@@ -109,11 +110,13 @@ func (m *WsMsg) Format() string {
109110
// Parse parses an incoming text buffer into a message.
110111
//
111112
// The wire format mirrors [WsMsg.Append]. For commands other than [what.Set] and
112-
// [what.Call], if the Data field begins with a double quote it is decoded with
113-
// [strconv.Unquote] and the message is rejected if that fails; data that does not
114-
// begin with a double quote is taken verbatim. Set and Call data is always taken
115-
// verbatim. In all cases the resulting data is sanitized with
116-
// [strings.ToValidUTF8].
113+
// [what.Call], if the Data field begins with a double quote it is decoded as a JSON
114+
// string: [strconv.Unquote] handles the common case, with a fallback to a JSON
115+
// string decode for inputs it rejects but the browser's JSON.stringify can produce
116+
// (notably a lone UTF-16 surrogate, which the fallback maps to U+FFFD). The message
117+
// is rejected only if both decoders fail. Data that does not begin with a double
118+
// quote is taken verbatim, as is all Set and Call data. In all cases the resulting
119+
// data is sanitized with [strings.ToValidUTF8].
117120
func Parse(txt []byte) (WsMsg, bool) {
118121
if len(txt) > 2 && txt[len(txt)-1] == '\n' {
119122
if nl1 := bytes.IndexByte(txt, '\t'); nl1 >= 0 {
@@ -125,8 +128,21 @@ func Parse(txt []byte) (WsMsg, bool) {
125128
if id := jid.ParseString(string(txt[nl1+1 : nl2])); id.IsValid() {
126129
data := string(txt[nl2+1 : len(txt)-1])
127130
if txt[nl2+1] == '"' && wht != what.Set && wht != what.Call {
128-
var err error
129-
if data, err = strconv.Unquote(data); err != nil {
131+
// The browser encodes this data with JSON.stringify.
132+
// strconv.Unquote decodes the common case cheaply and
133+
// allocation-free, but its grammar is not a superset of
134+
// JSON: it rejects the "\udXXX" lone-surrogate escapes
135+
// JSON.stringify can emit. Fall back to a JSON string
136+
// decode (which maps a lone surrogate to U+FFFD) so a
137+
// legitimate event is decoded rather than silently dropped;
138+
// the ToValidUTF8 below still sanitizes whatever survives.
139+
// The fallback lives in jsonUnquoteString so the address it
140+
// takes does not force data to the heap on every call.
141+
if unq, err := strconv.Unquote(data); err == nil {
142+
data = unq
143+
} else if unq, ok := jsonUnquoteString(data); ok {
144+
data = unq
145+
} else {
130146
return WsMsg{}, false
131147
}
132148
}
@@ -143,6 +159,15 @@ func Parse(txt []byte) (WsMsg, bool) {
143159
return WsMsg{}, false
144160
}
145161

162+
// jsonUnquoteString decodes s as a JSON string literal, returning ok=false if it
163+
// is not one. It exists as a separate function so that the address it must take of
164+
// its decode target does not force [Parse]'s data local to escape to the heap on
165+
// every call; see the fallback in Parse.
166+
func jsonUnquoteString(s string) (out string, ok bool) {
167+
ok = json.Unmarshal([]byte(s), &out) == nil
168+
return
169+
}
170+
146171
// FillAlert replaces m with an escaped danger alert for err.
147172
func (m *WsMsg) FillAlert(err error) {
148173
m.Jid = 0

‎lib/wire/wsmsg_benchmark_test.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package wire
2+
3+
import "testing"
4+
5+
var parseBenchSink WsMsg
6+
7+
// BenchmarkParse guards the inbound parse hot path (run on every WebSocket frame)
8+
// across the common command shapes, including the previously-dropped lone surrogate
9+
// that now decodes via the JSON fallback. The common quoted and unquoted paths must
10+
// stay allocation-light; only the rare surrogate case pays the JSON-decode cost.
11+
func BenchmarkParse(b *testing.B) {
12+
frames := []struct {
13+
name string
14+
frame []byte
15+
}{
16+
{"input_plain", []byte("Input\tJid.1\t\"hello world\"\n")},
17+
{"input_escaped", []byte("Input\tJid.1\t\"a\\nb\\tc\"\n")},
18+
{"input_surrogate", []byte("Input\tJid.1\t\"\\ud800\"\n")},
19+
{"input_unquoted", []byte("Input\tJid.1\ttrue\n")},
20+
{"set_verbatim", []byte("Set\tJid.1\tpath={\"a\":1}\n")},
21+
}
22+
for _, f := range frames {
23+
b.Run(f.name, func(b *testing.B) {
24+
b.ReportAllocs()
25+
b.ResetTimer()
26+
for i := 0; i < b.N; i++ {
27+
parseBenchSink, _ = Parse(f.frame)
28+
}
29+
})
30+
}
31+
}

‎lib/wire/wsmsg_test.go‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,29 @@ func Test_wsParse_IncompleteFails(t *testing.T) {
170170
}
171171
}
172172

173+
// Test_wsParse_InboundLoneSurrogate covers an inbound frame whose JSON-quoted data
174+
// holds a lone UTF-16 surrogate. The browser's JSON.stringify emits such a value as
175+
// the literal escape "\udXXX" for Input/Click/ContextMenu/Remove payloads (it does
176+
// not throw on lone surrogates), but strconv.Unquote rejects "\udXXX", which would
177+
// silently drop the whole event frame before the ToValidUTF8 sanitizer runs. Parse
178+
// must instead decode it, replacing the surrogate with U+FFFD, and deliver the event.
179+
func Test_wsParse_InboundLoneSurrogate(t *testing.T) {
180+
frame := "Input\tJid.1\t\"\\ud800\"\n" // data field is the 8-byte JSON string "\ud800"
181+
msg, ok := Parse([]byte(frame))
182+
if !ok {
183+
t.Fatalf("Parse dropped a frame with a lone surrogate: %q", frame)
184+
}
185+
if msg.What != what.Input || msg.Jid != jid.Jid(1) {
186+
t.Errorf("unexpected header: %+v", msg)
187+
}
188+
if !utf8.ValidString(msg.Data) {
189+
t.Errorf("Data is not valid UTF-8: %q", msg.Data)
190+
}
191+
if msg.Data != "�" {
192+
t.Errorf("got Data %q, want the replacement char %q", msg.Data, "�")
193+
}
194+
}
195+
173196
func Fuzz_wsParse(f *testing.F) {
174197
f.Add([]byte("Update\t\t\"\"\n"))
175198
f.Add([]byte("Click\t\t\"10 20 5 name\\tJid.1\"\n"))

0 commit comments

Comments
 (0)