Skip to content

JsonReader silently accepts \uXXXX escapes containing lone surrogates in string values and field names #3113

Closed

Description

@elang2

The problem

JsonReader.readEscapeCharacter() (JsonReader.java:1855) reads 4 hex digits, casts to (char), and returns unchecked at line 1882. No Character.isSurrogate / 0xD800..0xDFFF gate anywhere. Callers at line 1244 (string-value) and line 1340 (skip-string) consume the raw char with no validation. Both setLenient(false) and Strictness.STRICT accept the malformed input.

The specs

  • RFC 8259 §8.2: "behavior of software that receives JSON texts containing such values is unpredictable; for example, implementations might return different values for the length of a string value or even suffer fatal runtime exceptions."
  • RFC 7493 §2.1 (I-JSON): "Object member names, and string values in arrays and object members, MUST NOT include code points that identify Surrogates or Noncharacters as defined by [UNICODE]... \uDEAD is invalid because it is an unpaired surrogate."
  • RFC 8785 §3.2.2.2 (JCS): "occurrences of such data MUST cause a compliant JCS implementation to terminate with an appropriate error."

Reproducer (gson 2.14.0, JDK 21, Strictness.STRICT)

import com.google.gson.*;
import com.google.gson.stream.*;
import java.io.StringReader;

public class Repro3113 {
    public static void main(String[] args) throws Exception {
        String[] docs = {"{\"k\":\"\\uD800\"}", "{\"k\":\"\\uDC00\"}", "{\"\\uDC00\":1}"};
        for (String doc : docs) {
            JsonReader r = new JsonReader(new StringReader(doc));
            r.setStrictness(Strictness.STRICT);
            r.beginObject();
            String name = r.nextName();
            String val = r.peek() == JsonToken.NUMBER
                    ? String.valueOf(r.nextInt()) : r.nextString();
            System.out.println("input=" + doc
                + " ACCEPT name.char0=0x" + Integer.toHexString(name.charAt(0))
                + " val.char0=0x" + Integer.toHexString(val.charAt(0)));
        }
    }
}

Output:

input={"k":"\uD800"} ACCEPT name.char0=0x6b val.char0=0xd800
input={"k":"\uDC00"} ACCEPT name.char0=0x6b val.char0=0xdc00
input={"\uDC00":1}  ACCEPT name.char0=0xdc00 val.char0=0x31

How to read the output

  • input= shows the JSON document fed in.
  • ACCEPT means the parser returned successfully (no exception).
  • name.char0 is the hex value of the first char of the field name.
  • val.char0 is the hex value of the first char of the value.
  • Row 1: field name is "k" (0x6b), value's first char is 0xd800 — a lone high surrogate returned as-is in the Java String.
  • Row 2: same field name, value's first char is 0xdc00 — a lone low surrogate returned as-is.
  • Row 3: field NAME itself starts with 0xdc00 (a lone low surrogate); the value is just the digit 1 (0x31).

All three should have rejected under Strictness.STRICT per the RFC 7493 / RFC 8785 requirements above.

Suggested fix

The return at JsonReader.java:1882 is the site to add the guard. Rejecting under Strictness.STRICT preserves current behavior in Strictness.LEGACY_STRICT / Strictness.LENIENT — no Android-compat break.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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