Skip to content

fix: reject unpaired surrogates in strict JSON - #3116

Merged
eamonnmcmanus merged 2 commits into
google:mainfrom
rksharma-owg:codex/reject-unpaired-surrogates
Sep 9, 2026
Merged

eamonnmcmanus merged 2 commits into
google:mainfrom
rksharma-owg:codex/reject-unpaired-surrogates

Conversation

@rksharma-owg

Copy link
Copy Markdown
Contributor

Fixes #3113

JsonReader currently accepts \uD800 / \uDC00 escapes as lone UTF-16 surrogate code units even when Strictness.STRICT is selected. This can return malformed Java strings for both values and object member names. Validate the fully decoded quoted string at the boundary where it is returned, rejecting unpaired surrogates while allowing valid surrogate pairs and leaving legacy/lenient modes unchanged.

Validation

  • mvn -pl gson -Denforcer.skip=true -Dtest=com.google.gson.stream.JsonReaderTest test (164 tests, 3 skipped; JDK 26 requires the repository enforcer override)
  • git diff --check

The regression test covers escaped lone high/low surrogates in values and names, plus a valid escaped pair.

@eamonnmcmanus eamonnmcmanus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together! Just a couple of small things.

}
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (Character.isHighSurrogate(c)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a minor optimization, you could do this:

if (Character.isSurrogate(c)) {
  if (Character.isHighSurrogate(c)
      && i + 1 < value.length()
      && Character.isLowSurrogate(value.charAt(++i))) {
    continue;
  }
  throw syntaxError(...);
}

That way, in the common case where there are no surrogate characters, there is only one if per iteration rather than two.

I thought there might be some clever way to use CharsetEncoder.canEncode(CharSequence) here, but it doesn't look as if that would be very efficient.


@Test
public void testStrictModeRejectsUnpairedSurrogates() throws IOException {
for (String json : new String[] {"\"\\uD800\"", "\"\\uDC00\"", "{\"\\uD800\":1}"}) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the new code will also reject inputs that contain unpaired surrogates directly as characters, not expressed via \u escapes in the JSON. That makes sense to me, but can you test it too? I mean "\"\uD800\"" for example.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am wondering if raw unpaired surrogates are rather the responsibility of the user? The user is in control of how the underlying Reader is created, and could probably have prevented the unpaired surrogates if they wanted to. (But if the JSON is present as String already, then it would be cumbersome for the user to validate it; effectively duplicating the code of the PR here.)

I am mentioning this because I am wondering whether it would suffice to implement this validation only in the context of readEscapeCharacter() (maybe with an additional local boolean to store whether a low surrogate is expected).

The current implementation of this PR also has the side-effect that it allows creating surrogate pairs with mixed raw and escaped surrogates, as long as they form a valid pair in the end. Maybe a bit weird, but not necessarily bad?

But maybe this is not as important; the current implementation of this PR is fine too.

(Sorry that this comment is a bit late now.)

@rksharma-owg

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback in commit 1b4fc9b:\n\n- Added coverage for an unpaired surrogate present directly in the JSON input, alongside escaped values and names.\n- Simplified the strict-mode validation loop to check Character.isSurrogate once per character and skip valid pairs.\n\nThe focused JsonReaderTest suite passes: 164 tests, 3 skipped.

@eamonnmcmanus eamonnmcmanus left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@eamonnmcmanus
eamonnmcmanus merged commit 8b4b550 into google:main Sep 9, 2026
21 checks passed
sushant-me added a commit to sushant-me/gson that referenced this pull request Sep 15, 2026
JsonReader has rejected unpaired UTF-16 surrogates in STRICT mode since
PR google#3116, but JsonWriter still writes them. That produces a document which
this class's own documentation promises conforms to RFC 8259 but which no
conforming parser can read back, and which is silently replaced by '?' when
the document is encoded as UTF-8. Gson's own default output is therefore
unreadable by Gson in STRICT mode.

Validate the value on the write path the same way the read path does, so
the two sides of the API agree on what a strict JSON string is. The check is
limited to Strictness.STRICT so callers of the legacy permissive modes see
no behaviour change.

Adds writer-side tests mirroring the existing JsonReaderTest coverage; the
writer previously had none.
sushant-me added a commit to sushant-me/gson that referenced this pull request Sep 15, 2026
JsonReader has rejected unpaired UTF-16 surrogates in STRICT mode since
PR google#3116, but JsonWriter still writes them. That produces a document which
this class's own documentation promises conforms to RFC 8259 but which no
conforming parser can read back, and which is silently replaced by '?' when
the document is encoded as UTF-8. Gson's own default output is therefore
unreadable by Gson in STRICT mode.

Validate the value on the write path the same way the read path does, so
the two sides of the API agree on what a strict JSON string is. The check is
limited to Strictness.STRICT so callers of the legacy permissive modes see
no behaviour change.

Adds writer-side tests mirroring the existing JsonReaderTest coverage; the
writer previously had none.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants