mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
c7ae27807f
commit
f196ebf806
2 files changed
+63
-7
No files matched your search
@@ -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: [],
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user