fix(apple): refuse unpaired surrogates in IPS JSON strings (#6990)

The IPS JSON reader decodes \uXXXX escapes with String.fromCharCode and
returns raw slices verbatim, so an escaped unpaired surrogate reaches
Evidence as-is: malformed text reported under complete coverage, with no
refusal and no uncertainty. A crash report's procName or exception message
comes from the crashed process, so the escape is reachable input.

Refuse the string the way the parser already refuses other malformed
content. The check runs on the decoded value, so it covers escaped and
unescaped text alike, and a high surrogate followed by its low surrogate
escape stays valid text.

Validation: RED then green in src/native/apple/losslessJson.test.ts, where
the new case previously accepted the escaped surrogate; 311 tests across
the apple parser suites pass; tsc --noEmit over the changed set exits 0;
oxlint reports 0 errors; oxfmt --check is clean; verify:module-boundaries
passes.

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Dery Ferdika
2026-10-25 02:59:10 +07:00
committed by GitHub
parent c7ae27807f
commit f196ebf806
2 changed files with 63 additions and 7 deletions
+29
View File
@@ -126,3 +126,32 @@ it("refuses nesting beyond the selected depth in kept and skipped values", () =>
).toThrow(expect.objectContaining({ reason: "depth" }));
expect(parseLosslessJson(deep, 0, undefined, { maxDepth: 20 }).end).toBe(40);
});
it("refuses JSON string escapes for unpaired surrogates", () => {
for (const text of ['{"a":"\\ud800"}', '{"a":"ok\\udfff"}'])
expect(() => parseLosslessJson(text, 0)).toThrow(
expect.objectContaining({
message: "JSON string contains an unpaired surrogate",
}),
);
expect(() => parseLosslessJson('{"a":"\\ud83d\\ude00"}', 0)).not.toThrow();
});
it("applies the surrogate check to skipped strings and nested keys", () => {
const skipped: JsonShape = { kind: "object", members: {} };
for (const text of [
'{"extra":"\\ud800"}',
'{"extra":["\\ud800"]}',
'{"extra":{"\\ud800":1}}',
])
expect(() => parseLosslessJson(text, 0, skipped)).toThrow(
expect.objectContaining({
message: "JSON string contains an unpaired surrogate",
}),
);
expect(parseLosslessJson('{"extra":"ok"}', 0, skipped).value).toEqual({
kind: "object",
members: {},
unlisted: [],
});
});
+34 -7
View File
@@ -254,7 +254,7 @@ class Parser {
throw new LosslessJsonFailure(
`Expected a JSON object key at offset ${String(this.position)}`,
);
this.skipString();
this.parseString();
this.skipWhitespace();
this.expect(":");
this.skipValue(depth + 1);
@@ -295,7 +295,7 @@ class Parser {
}
}
if (ch === '"') {
this.skipString();
this.parseString();
return;
}
if (ch === "-" || (ch !== undefined && isDigit(ch.charCodeAt(0)))) {
@@ -309,13 +309,40 @@ class Parser {
private parseString(): string {
const start = this.position;
const escaped = this.skipString();
if (!escaped) return this.text.slice(start + 1, this.position - 1);
// A decoded JSON string cannot contain more code units than its literal.
// The input's byte allowance bounds this allocation, including dense escapes.
const decoded: unknown = JSON.parse(this.text.slice(start, this.position));
const decoded: unknown = escaped
? // A decoded JSON string cannot contain more code units than its literal.
// The input's byte allowance bounds this allocation, including dense escapes.
JSON.parse(this.text.slice(start, this.position))
: this.text.slice(start + 1, this.position - 1);
if (typeof decoded !== "string")
throw new LosslessJsonFailure("Expected a JSON string");
return decoded;
return this.requirePairedSurrogates(decoded);
}
/**
* JSON admits \uXXXX escapes for unpaired surrogates, but the decoded text is
* not valid Unicode. Evidence carrying one would report malformed text as
* complete, so the parser refuses the string like any other malformed input.
* A high surrogate followed by its low surrogate escape stays valid text.
*/
private requirePairedSurrogates(value: string): string {
for (let index = 0; index < value.length; index += 1) {
const unit = value.charCodeAt(index);
if (unit >= 0xd800 && unit <= 0xdbff) {
const next = index + 1 < value.length ? value.charCodeAt(index + 1) : 0;
if (next < 0xdc00 || next > 0xdfff)
throw new LosslessJsonFailure(
"JSON string contains an unpaired surrogate",
);
index += 1;
continue;
}
if (unit >= 0xdc00 && unit <= 0xdfff)
throw new LosslessJsonFailure(
"JSON string contains an unpaired surrogate",
);
}
return value;
}
private skipString(): boolean {