Skip to content

json: parser constructs ill-formed strings from unpaired \uXXXX escape sequences #4062

Description

@bobzhang

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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions