Skip to content

Commit e9b1e35

Browse files
committed
fix: reject unsafe incomplete json ranges
1 parent 1a5b90e commit e9b1e35

15 files changed

Lines changed: 270 additions & 86 deletions

File tree

assembly/__tests__/json-runtime.spec.ts

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -555,15 +555,12 @@ describe("SWAR: JSON.Value[] field with empty array covers deserializeGenericArr
555555
expect(h.vals.length).toBe(0);
556556
});
557557

558-
// NAIVE: JSON.Arr parseArrayBodySlots return-srcEnd
559-
// deserializeJsonArray validates the first/last chars ('['/']') but does NOT
560-
// require them to match. For '[[1,2]' the last char IS ']' (valid), but it
561-
// belongs to the inner array — the outer loop exhausts srcStart without finding
562-
// the outer ']', hitting the `return srcEnd` path at line 129. All modes call
563-
// the same naive deserializeJsonArray for JSON.Arr.
564-
describe("NAIVE: JSON.Arr with outer missing ']' covers parseArrayBodySlots return-srcEnd", () => {
565-
const arr = JSON.parse<JSON.Arr>("[[1,2]");
566-
expect(arr.length).toBe(1);
558+
// The final ']' belongs to the inner array. Source exhaustion is not a valid
559+
// substitute for the outer array's own closing delimiter.
560+
describe("JSON.Arr rejects an inner array that steals the outer closing bracket", () => {
561+
expect((): void => {
562+
JSON.parse<JSON.Arr>("[[1,2]");
563+
}).toThrow();
567564
});
568565

569566
// swar/array/shared.ts: backslash in string element → scanQuotedValueEnd
@@ -598,11 +595,12 @@ describe("SWAR: JSON.Obj[] empty array as @json class field", () => {
598595
expect(o.items.length).toBe(0);
599596
});
600597

601-
// naive/object.ts parseObjectBody:329 — inner object consumes the outer } so parseObjectBody
602-
// exhausts srcEnd and returns srcEnd (success path, no throw needed)
603-
describe("JSON.Obj: parseObjectBody returns srcEnd when inner object uses the closing brace (naive/object.ts:329)", () => {
604-
const obj = JSON.parse<JSON.Obj>('{"k":{"a":1}');
605-
expect(obj.has("k")).toBe(true);
598+
// An inner object must not consume its owner's closing brace and turn source
599+
// exhaustion into a successful parse.
600+
describe("JSON.Obj rejects an inner object that steals the outer closing brace", () => {
601+
expect((): void => {
602+
JSON.parse<JSON.Obj>('{"k":{"a":1}');
603+
}).toThrow();
606604
});
607605

608606
// swar/array/object.ts:51 — shrink path: reusing a JSON.Obj[] with more elements than the new parse
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { JSON } from "..";
2+
import { describe, expect } from "as-test";
3+
4+
5+
@json
6+
class ProductionSafetyStruct {
7+
dynamic: JSON.Value = JSON.Value.empty();
8+
}
9+
10+
11+
@json
12+
class ProductionSafetyMapStruct {
13+
lookup: Map<string, JSON.Value> = new Map<string, JSON.Value>();
14+
}
15+
16+
17+
@json({ lazy: "all" })
18+
class ProductionSafetyLazyStruct {
19+
dynamic: JSON.Lazy<JSON.Obj> = new JSON.Obj();
20+
lookup: JSON.Lazy<Map<string, JSON.Value>> = new Map<string, JSON.Value>();
21+
}
22+
23+
let malformedInput = "";
24+
25+
function expectProductionReject<T>(data: string): void {
26+
malformedInput = data;
27+
expect((): void => {
28+
JSON.parse<T>(malformedInput);
29+
}).toThrow();
30+
}
31+
32+
describe("production parsing rejects incomplete source ranges", () => {
33+
// Lazy values must never retain an absent/zero end pointer. Materializing or
34+
// serializing such a slice can otherwise read outside the source value.
35+
expectProductionReject<JSON.Value>('"unterminated');
36+
expectProductionReject<JSON.Value>('{"a":1');
37+
expectProductionReject<JSON.Value>("[1,2");
38+
expectProductionReject<JSON.Value[]>('[{"a":1]');
39+
40+
// A nested value must not consume its owner's closing delimiter and let the
41+
// owner report success after merely exhausting srcEnd.
42+
expectProductionReject<JSON.Obj>('{"k":{"a":1}');
43+
expectProductionReject<JSON.Arr>("[[1]");
44+
expectProductionReject<ProductionSafetyStruct>('{"dynamic":{"a":1}');
45+
expectProductionReject<ProductionSafetyMapStruct>('{"lookup":{"k":{"a":1}}');
46+
expectProductionReject<ProductionSafetyLazyStruct>('{"dynamic":{"a":1}');
47+
expectProductionReject<ProductionSafetyLazyStruct>('{"lookup":{"k":{"a":1}}');
48+
expectProductionReject<Map<string, JSON.Value>>('{"k":{"a":1}');
49+
expectProductionReject<Map<string, JSON.Obj>>('{"k":{"a":1}');
50+
51+
// Whole-value decoders perform wide loads and quote stripping, so truncated
52+
// roots need explicit bounds guards in ordinary production builds.
53+
expectProductionReject<JSON.Value>("t");
54+
expectProductionReject<JSON.Value>("fals");
55+
expectProductionReject<JSON.Value>("nul");
56+
expectProductionReject<bool>("t");
57+
expectProductionReject<string>('"');
58+
});
Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,26 @@
11
import { JSON } from "../..";
2-
import { deserializeBoolean } from "./bool";
2+
import { deserializeBooleanCode } from "./bool";
33
import { deserializeFloat } from "./float";
44
import { deserializeObject, deserializeJsonArray, getParseSrc } from "./object";
55
import { deserializeString } from "./string";
66
import { BRACE_LEFT, BRACKET_LEFT, CHAR_N, QUOTE } from "../../custom/chars";
77

8+
const NULL_WORD: u64 = 30399761348886638;
9+
810
export function deserializeArbitrary(
911
srcStart: usize,
1012
srcEnd: usize,
1113
dst: usize,
1214
): JSON.Value {
1315
const v = parseArbitraryValue(srcStart, srcEnd);
16+
if (changetype<usize>(v) == 0) return v;
1417
// Reuse path (`JSON.parse<JSON.Value>(data, out)`): write the parsed bits into
1518
// the caller's handle (with the GC barrier for any managed payload).
1619
return dst != 0 ? JSON.Value.__adoptInto(dst, v) : v;
1720
}
1821

1922
function parseArbitraryValue(srcStart: usize, srcEnd: usize): JSON.Value {
23+
if (srcStart >= srcEnd) return changetype<JSON.Value>(0);
2024
const firstChar = load<u16>(srcStart);
2125
if (
2226
firstChar == QUOTE ||
@@ -29,19 +33,36 @@ function parseArbitraryValue(srcStart: usize, srcEnd: usize): JSON.Value {
2933
const src = getParseSrc();
3034
if (src.length != 0) {
3135
const end = JSON.Util.scanValueEnd<JSON.Value>(srcStart, srcEnd);
36+
// A zero end is the scanner's failure sentinel. Never retain it in a
37+
// lazy slice: subtracting the source base from zero would underflow and
38+
// later materialization could read outside the source value.
39+
if (end == 0) return changetype<JSON.Value>(0);
3240
return JSON.Value.fromSlice(srcStart, end, src);
3341
}
34-
if (firstChar == QUOTE)
35-
return JSON.Value.from(deserializeString(srcStart, srcEnd));
36-
return firstChar == BRACE_LEFT
37-
? JSON.Value.from(deserializeObject(srcStart, srcEnd, 0))
38-
: JSON.Value.from(deserializeJsonArray(srcStart, srcEnd, 0));
42+
if (firstChar == QUOTE) {
43+
const value = deserializeString(srcStart, srcEnd);
44+
return changetype<usize>(value) != 0
45+
? JSON.Value.from(value)
46+
: changetype<JSON.Value>(0);
47+
}
48+
const composite =
49+
firstChar == BRACE_LEFT
50+
? changetype<usize>(deserializeObject(srcStart, srcEnd, 0))
51+
: changetype<usize>(deserializeJsonArray(srcStart, srcEnd, 0));
52+
return composite != 0
53+
? firstChar == BRACE_LEFT
54+
? JSON.Value.from(changetype<JSON.Obj>(composite))
55+
: JSON.Value.from(changetype<JSON.Arr>(composite))
56+
: changetype<JSON.Value>(0);
3957
} else if (firstChar - 48 <= 9 || firstChar == 45) {
4058
return JSON.Value.from(deserializeFloat<f64>(srcStart, srcEnd));
4159
} else if (firstChar == 116 || firstChar == 102) {
42-
return JSON.Value.from(deserializeBoolean(srcStart, srcEnd));
60+
const code = deserializeBooleanCode(srcStart, srcEnd);
61+
return code != 0 ? JSON.Value.from(code == 2) : changetype<JSON.Value>(0);
4362
} else if (firstChar == CHAR_N) {
63+
if (srcEnd - srcStart < 8 || load<u64>(srcStart) != NULL_WORD)
64+
return changetype<JSON.Value>(0);
4465
return JSON.Value.from<usize>(0);
4566
}
46-
return unreachable();
67+
return changetype<JSON.Value>(0);
4768
}

assembly/deserialize/index/bool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { deserializeBoolean } from "../naive/bool";
1+
export { deserializeBoolean, deserializeBooleanCode } from "../naive/bool";

assembly/deserialize/index/object.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,6 @@ export {
33
deserializeJsonArray,
44
setParseSrc,
55
getParseSrc,
6+
markProductionParseError,
7+
takeProductionParseError,
68
} from "../naive/object";

assembly/deserialize/index/string.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ import {
1313
} from "../swar/string";
1414

1515
export function deserializeString(srcStart: usize, srcEnd: usize): string {
16+
// Whole-value decoders strip two UTF-16 code units before entering their
17+
// optimized loops. Guard the actual memory-safety invariant here; complete
18+
// RFC quote framing remains the strict-mode validator's job.
19+
if (srcEnd - srcStart < 4) return changetype<string>(0);
1620
if (JSON_MODE == JSONMode.SIMD) {
1721
return deserializeString_SIMD(srcStart, srcEnd);
1822
} else if (JSON_MODE == JSONMode.NAIVE) {

assembly/deserialize/naive/array/arbitrary.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export function deserializeArbitraryArray(
1010
dst || changetype<usize>(instantiate<JSON.Value[]>()),
1111
);
1212
// Skip the opening '[' and parse elements single-pass until the matching ']'.
13-
parseArrayBody(out, srcStart + 2, srcEnd);
14-
return out;
13+
return parseArrayBody(out, srcStart + 2, srcEnd) != 0
14+
? out
15+
: changetype<JSON.Value[]>(0);
1516
}

assembly/deserialize/naive/array/array.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function deserializeArrayArray<T extends unknown[][]>(
2828
if (load<u16>(srcStart) == BRACKET_LEFT) {
2929
const inner = instantiate<JSON.Value[]>();
3030
srcStart = parseArrayBody(inner, srcStart + 2, srcEnd);
31+
if (srcStart == 0) return changetype<T>(0);
3132
// @ts-ignore: valueof<T> is JSON.Value[] in this branch
3233
out.push(changetype<valueof<T>>(changetype<usize>(inner)));
3334
} else {

assembly/deserialize/naive/array/object.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function deserializeObjectArray<T extends unknown[]>(
3434
if (load<u16>(srcStart) == BRACE_LEFT) {
3535
const obj = new JSON.Obj();
3636
srcStart = parseObjectBody(obj, srcStart + 2, srcEnd);
37+
if (srcStart == 0) return changetype<T>(0);
3738
out.push(changetype<valueof<T>>(changetype<usize>(obj)));
3839
} else {
3940
srcStart += 2;

assembly/deserialize/naive/bool.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1+
// 0 = invalid, 1 = false, 2 = true. The status form lets public dispatchers
2+
// raise malformed-input errors at their catchable boundary without rescanning.
3+
export function deserializeBooleanCode(srcStart: usize, srcEnd: usize): u32 {
4+
const length = srcEnd - srcStart;
5+
if (length >= 8 && load<u64>(srcStart) == 28429475166421108) return 2;
6+
if (
7+
length >= 10 &&
8+
load<u64>(srcStart) == 32370086184550502 &&
9+
load<u16>(srcStart, 8) == 101
10+
)
11+
return 1;
12+
return 0;
13+
}
14+
115
export function deserializeBoolean(srcStart: usize, srcEnd: usize): boolean {
2-
const block = load<u64>(srcStart);
3-
if (block == 28429475166421108) return true;
4-
else if (block == 32370086184550502 && load<u16>(srcStart, 8) == 101)
5-
return false;
6-
return false; //throw new Error(`Expected to find boolean, but found "${data.slice(0, 100)}" instead!`);
16+
const code = deserializeBooleanCode(srcStart, srcEnd);
17+
if (code != 0) return code == 2;
18+
throw new Error("Expected 'true' or 'false' in JSON");
719
}

0 commit comments

Comments
 (0)