Summary
@json.parse decodes every \uXXXX escape by writing the hex value directly into the result (buf.write_char(c.unsafe_to_char()) in json/lex_string.mbt, lex_string_slow). For surrogate-range values this manufactures an ill-formed, lone-surrogate MoonBit string out of perfectly valid ASCII JSON input:
test {
let s = @json.parse("\"\\uD800\"")
// s is String containing the unpaired leading surrogate U+D800 —
// an ill-formed UTF-16 sequence no safe API should be able to produce.
}
This violates the String well-formedness invariant ("we should maintain String unicode safe, so unsafe_to_char is indeed unsafe"): user code that never touches unsafe APIs can end up holding ill-formed strings simply by parsing attacker-controlled ASCII JSON.
Spec position
- RFC 8259 §8.2 explicitly flags unpaired surrogate escapes as unpredictable-behavior territory: the escape syntax is grammatically valid, but "the behavior of software that receives JSON texts containing such values is unpredictable".
- RFC 7493 (I-JSON) forbids them outright: messages MUST NOT contain
\uXXXX escapes representing unpaired surrogates.
- Ecosystem: JS
JSON.parse and Python json accept them (producing ill-formed strings), Go encoding/json substitutes U+FFFD, serde_json (Rust) rejects with a parse error. Under the Rust-style well-formedness stance, we align with serde_json: reject with the documented ParseError.
Proposed resolution
In lex_string_slow's \u handling: a leading-surrogate escape (\uD800–\uDBFF) must be immediately followed by a trailing-surrogate escape (\uDC00–\uDFFF) and the pair combined into one Unicode scalar value; a bare trailing-surrogate escape, an unpaired leading-surrogate escape, or a non-escape following a leading-surrogate escape raise InvalidChar. Valid escaped pairs (e.g. \uD83D\uDE00 → U+1F600) keep parsing exactly as before.
This is a behavior change: "\uD800" currently parses successfully and would become a parse error.
Affected targets
All backends — wasm-gc, js, native (shared code).
Related: #4049 covers the raw (unescaped) lone-surrogate side, including the process abort. Found during adversarial QuickCheck testing (#4045).
Summary
@json.parsedecodes every\uXXXXescape by writing the hex value directly into the result (buf.write_char(c.unsafe_to_char())injson/lex_string.mbt,lex_string_slow). For surrogate-range values this manufactures an ill-formed, lone-surrogate MoonBit string out of perfectly valid ASCII JSON input:This violates the String well-formedness invariant ("we should maintain String unicode safe, so
unsafe_to_charis indeed unsafe"): user code that never touches unsafe APIs can end up holding ill-formed strings simply by parsing attacker-controlled ASCII JSON.Spec position
\uXXXXescapes representing unpaired surrogates.JSON.parseand Pythonjsonaccept them (producing ill-formed strings), Goencoding/jsonsubstitutes U+FFFD, serde_json (Rust) rejects with a parse error. Under the Rust-style well-formedness stance, we align with serde_json: reject with the documentedParseError.Proposed resolution
In
lex_string_slow's\uhandling: a leading-surrogate escape (\uD800–\uDBFF) must be immediately followed by a trailing-surrogate escape (\uDC00–\uDFFF) and the pair combined into one Unicode scalar value; a bare trailing-surrogate escape, an unpaired leading-surrogate escape, or a non-escape following a leading-surrogate escape raiseInvalidChar. Valid escaped pairs (e.g.\uD83D\uDE00→ U+1F600) keep parsing exactly as before.This is a behavior change:
"\uD800"currently parses successfully and would become a parse error.Affected targets
All backends — wasm-gc, js, native (shared code).
Related: #4049 covers the raw (unescaped) lone-surrogate side, including the process abort. Found during adversarial QuickCheck testing (#4045).