From f196ebf80665107aedc16892af25a1e1f9f26f43 Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:59:10 +0700 Subject: [PATCH] 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> --- src/native/apple/losslessJson.test.ts | 29 +++++++++++++++++++ src/native/apple/losslessJson.ts | 41 ++++++++++++++++++++++----- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/native/apple/losslessJson.test.ts b/src/native/apple/losslessJson.test.ts index 7ad511ee9f..42c4b74588 100644 --- a/src/native/apple/losslessJson.test.ts +++ b/src/native/apple/losslessJson.test.ts @@ -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: [], + }); +}); diff --git a/src/native/apple/losslessJson.ts b/src/native/apple/losslessJson.ts index f3777ad848..451a6a95fc 100644 --- a/src/native/apple/losslessJson.ts +++ b/src/native/apple/losslessJson.ts @@ -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 {