Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions changelog.d/7792-json-parse-nesting-depth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
`JSON.parse` no longer crashes the process on deeply nested input.

A document nested a few tens of thousands of levels deep did not throw — it
killed the process with SIGSEGV and printed nothing at all, so a program had
no way to see what happened, let alone recover:

```
node → parses it
scriptc → throws a catchable RangeError
perry → SIGSEGV, exit 139, no output
```

Deeply nested JSON is a well-known shape for untrusted input, which is what
makes a crash the wrong answer even though such a document is unusual.

Two parsers read the text, and both descend one function call per nesting
level: the syntax-validation pass and Perry's own value parser. Deep enough
input exhausts the stack in whichever reaches it first.

`JSON.parse` now measures nesting depth first and throws a catchable
`RangeError` when it exceeds 1,000 levels. The measurement is a single
non-recursive scan of the text, which matters more than it sounds: a recursive
depth check would crash on exactly the documents it exists to reject. It also
runs *before* syntax validation, since that pass recurses too and would crash
first otherwise — which means the scan sees malformed input and has to cope
with it, so brackets inside strings do not count and a stray closing bracket
clamps at zero instead of underflowing.

The limit is 1,000 because it has to be safe on the *smallest* stack in the
process, not the largest. Perry parses JSON on worker threads as well as the
main thread, and a 2 MiB thread stack overflows far earlier than the main
thread's 8 MB does. A first attempt used 10,000, taken from a main-thread
measurement, and the unit test crashed the test harness at 9,999 levels — so
the number is what a small stack can carry, not what a big one can. It matches
the depth Python's parser settled on, and real documents are not close: JSON
nested past a hundred levels is already unusual.

This is a deliberate gap against Node, which parses far deeper because V8's
parser is iterative and consumes no stack per level. Closing it means making
Perry's parser iterative too, tracked separately. Until then a catchable error
is strictly better than a crash.

Refs #7792.
55 changes: 55 additions & 0 deletions crates/perry-runtime/src/json/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,61 @@ mod tests {
}
}

/// #7792 — deeply nested input must throw, not take the process out.
///
/// Both parsers that read the document recurse once per nesting level, so
/// a deep enough document exhausted the stack: SIGSEGV, exit 139, no
/// output at all. The input shape here is a well-known one for untrusted
/// data, which is why a crash is the wrong answer even though a very deep
/// document is unusual.
mod nesting_depth {
use super::*;
use crate::json::parser::{nesting_depth_exceeds, MAX_NESTING_DEPTH};

#[test]
fn the_scan_counts_only_structural_brackets() {
assert!(!nesting_depth_exceeds(b"[[[]]]", 8));
assert!(nesting_depth_exceeds(b"[[[[[[[[[[]]]]]]]]]]", 4));
assert!(!nesting_depth_exceeds(br#"{"a":{"b":1}}"#, 4));
assert!(nesting_depth_exceeds(br#"{"a":{"b":1}}"#, 1));

// Brackets inside a string are text, not structure. Without this a
// single long string value would be rejected as deep nesting.
assert!(!nesting_depth_exceeds(br#"{"a":"[[[[[[[[[["}"#, 3));
// ...including one that ends in an escaped quote, so the scanner
// does not lose track of where the string closes.
assert!(!nesting_depth_exceeds(br#"{"a":"[[[\"[[["}"#, 3));

// The scan runs BEFORE syntax validation, so it sees malformed
// input. An unbalanced closer must clamp, not underflow.
assert!(!nesting_depth_exceeds(b"]]]]]]", 2));
assert!(!nesting_depth_exceeds(b"", 0));
}

/// The limit is the point of the change, so pin the boundary itself:
/// one level under passes, one level over is refused.
#[test]
fn parse_refuses_input_past_the_limit_and_accepts_input_under_it() {
let ok_depth = MAX_NESTING_DEPTH - 1;
let mut ok = vec![b'['; ok_depth];
ok.extend(std::iter::repeat(b']').take(ok_depth));
let text = js_string_from_bytes(ok.as_ptr(), ok.len() as u32);
assert!(
unsafe { js_json_parse_result(text) }.is_ok(),
"input inside the limit must still parse"
);
Comment on lines +849 to +860

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test the exact accepted maximum.

The test accepts depth MAX_NESTING_DEPTH - 1 and rejects MAX_NESTING_DEPTH + 1. An implementation that rejects depth MAX_NESTING_DEPTH would still pass. Assert that exactly MAX_NESTING_DEPTH parses successfully.

Proposed fix
-            let ok_depth = MAX_NESTING_DEPTH - 1;
+            let ok_depth = MAX_NESTING_DEPTH;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// The limit is the point of the change, so pin the boundary itself:
/// one level under passes, one level over is refused.
#[test]
fn parse_refuses_input_past_the_limit_and_accepts_input_under_it() {
let ok_depth = MAX_NESTING_DEPTH - 1;
let mut ok = vec![b'['; ok_depth];
ok.extend(std::iter::repeat(b']').take(ok_depth));
let text = js_string_from_bytes(ok.as_ptr(), ok.len() as u32);
assert!(
unsafe { js_json_parse_result(text) }.is_ok(),
"input inside the limit must still parse"
);
/// The limit is the point of the change, so pin the boundary itself:
/// one level under passes, one level over is refused.
#[test]
fn parse_refuses_input_past_the_limit_and_accepts_input_under_it() {
let ok_depth = MAX_NESTING_DEPTH;
let mut ok = vec![b'['; ok_depth];
ok.extend(std::iter::repeat(b']').take(ok_depth));
let text = js_string_from_bytes(ok.as_ptr(), ok.len() as u32);
assert!(
unsafe { js_json_parse_result(text) }.is_ok(),
"input inside the limit must still parse"
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-runtime/src/json/mod.rs` around lines 849 - 860, Update
parse_refuses_input_past_the_limit_and_accepts_input_under_it to construct and
parse a balanced input at exactly MAX_NESTING_DEPTH, asserting success; retain
the existing over-limit rejection coverage so the test verifies both boundary
acceptance and refusal beyond it.


let deep_depth = MAX_NESTING_DEPTH + 1;
let mut deep = vec![b'['; deep_depth];
deep.extend(std::iter::repeat(b']').take(deep_depth));
let text = js_string_from_bytes(deep.as_ptr(), deep.len() as u32);
assert!(
unsafe { js_json_parse_result(text) }.is_err(),
"input past the limit must be refused rather than descended into"
);
}
}

#[test]
fn parse_result_streaming_validation_rejects_malformed_and_trailing_input() {
for input in [
Expand Down
51 changes: 51 additions & 0 deletions crates/perry-runtime/src/json/parse_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,40 @@ fn syntax_error_value(message: &str) -> f64 {
f64::from_bits(JSValue::pointer(err as *const u8).bits())
}

/// A catchable `RangeError`, for the one JSON failure that is about size
/// rather than shape: input nested deeper than the parser can descend.
fn range_error_value(message: &str) -> f64 {
let msg_ptr = js_string_from_bytes(message.as_ptr(), message.len() as u32);
let err = crate::error::js_rangeerror_new(msg_ptr);
f64::from_bits(JSValue::pointer(err as *const u8).bits())
}

fn throw_syntax_error(message: &str) -> ! {
crate::exception::js_throw(syntax_error_value(message))
}

fn throw_range_error(message: &str) -> ! {
crate::exception::js_throw(range_error_value(message))
}

/// The one depth check, called by every entry that is about to descend.
///
/// `js_json_parse` and `js_json_parse_result` are separate implementations of
/// the same flow, and the typed-array path is a third. Sharing the decision is
/// what keeps them from drifting — the first version of this fix guarded only
/// one of the three and appeared to do nothing at all, because the entry point
/// codegen actually calls was one of the other two.
fn nesting_is_too_deep(bytes: &[u8]) -> bool {
crate::json::parser::nesting_depth_exceeds(bytes, crate::json::parser::MAX_NESTING_DEPTH)
}

fn too_deep_message() -> String {
format!(
"JSON.parse: input nested deeper than {} levels",
crate::json::parser::MAX_NESTING_DEPTH
)
}

fn is_json_null_literal(bytes: &[u8]) -> bool {
let Some(start) = bytes
.iter()
Expand Down Expand Up @@ -106,6 +136,14 @@ pub unsafe fn js_json_parse_result(text_ptr: *const StringHeader) -> Result<JSVa
return Err(syntax_error_value("Unexpected end of JSON input"));
}

// #7792: depth first, BEFORE the validation pass below. That pass recurses
// once per nesting level itself, so a check placed after it would run after
// the crash it exists to prevent. The scan is one linear pass over bytes we
// are about to read anyway.
if nesting_is_too_deep(bytes) {
return Err(range_error_value(&too_deep_message()));
}

// Validate without constructing a second full JSON tree. The Perry parser
// below owns the runtime representation; asking serde_json for `Value`
// here doubled peak live memory (and allocation work) on large payloads.
Expand Down Expand Up @@ -198,6 +236,12 @@ pub unsafe extern "C" fn js_json_parse(text_ptr: *const StringHeader) -> JSValue
if len == 0 {
throw_syntax_error("Unexpected end of JSON input");
}
// #7792: depth first, ahead of the validation pass, for the same reason as
// the `_result` twin above. This is the entry codegen emits, so a guard
// that covered only the twin covered nothing a compiled program can reach.
if nesting_is_too_deep(bytes) {
throw_range_error(&too_deep_message());
}
// Keep serde_json's strict syntax validation, but discard tokens as they
// are read instead of allocating an intermediate `serde_json::Value`
// immediately before Perry builds its own tree.
Expand Down Expand Up @@ -515,6 +559,13 @@ pub unsafe extern "C" fn js_json_parse_typed_array(
let data_ptr = (text_ptr as *const u8).add(std::mem::size_of::<StringHeader>());
let bytes = std::slice::from_raw_parts(data_ptr, len);

// #7792: this path builds its own parser, so it needs its own guard. Hand
// deep input to the generic entry rather than repeating the error here, so
// both report it identically.
if nesting_is_too_deep(bytes) {
return js_json_parse(text_ptr);
}

// Build the shape hint once. The keys_array + pre-interned key
// pointers are owned by longlived arena + shape-cache structures,
// so they outlive the parse and survive any intervening GC.
Expand Down
65 changes: 65 additions & 0 deletions crates/perry-runtime/src/json/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,71 @@ pub(crate) struct ObjectShapeHint {
pub(crate) field_count: u32,
}

/// The deepest `[`/`{` nesting `JSON.parse` will accept.
///
/// Both parsers that see the input recurse once per level — the `serde_json`
/// validation pass and Perry's own value parser — so a deep enough document
/// exhausts the stack and takes the whole process out with SIGSEGV, no
/// diagnostic and no output, on input that is very often attacker-supplied
/// (#7792). Measured on a default 8 MB main-thread stack the crash lands
/// between 20,000 and 40,000 levels — but that is the most generous stack in
/// the process, and it is the wrong one to size against. Perry parses JSON on
/// `perry/thread` workers and tokio workers too, and a 2 MiB thread stack
/// overflows well before 10,000 levels: a first attempt at this limit picked
/// 10,000 off the main-thread measurement, and the unit test below promptly
/// crashed the test harness at 9,999.
///
/// So the limit is sized for the SMALLEST stack in the process, not the
/// largest, and 1,000 is the same depth Python's parser has settled on. Real
/// documents do not come close: JSON nested past a hundred levels is already
/// unusual, and past a thousand is a machine talking to itself.
///
/// This is a deliberate parity gap. Node parses far deeper than this because
/// V8's parser is iterative and does not consume stack per level; matching it
/// means making this parser iterative too, which is the follow-up. Until then
/// a catchable error beats a SIGSEGV on untrusted input.
pub(crate) const MAX_NESTING_DEPTH: usize = 1_000;

/// Does `bytes` nest deeper than `limit`?
///
/// Iterative on purpose. A recursive depth check would be the very thing it
/// exists to prevent, and it would crash on exactly the documents it is
/// supposed to reject.
///
/// Bracket bytes inside strings do not count, so a document that is one long
/// `"[[[[[[…"` string is not mistaken for deep nesting. This runs before any
/// syntax validation, so it must not assume the input is well-formed — an
/// unbalanced `]` clamps at zero rather than underflowing.
pub(crate) fn nesting_depth_exceeds(bytes: &[u8], limit: usize) -> bool {
let mut depth = 0usize;
let mut in_string = false;
let mut escaped = false;
for &byte in bytes {
if in_string {
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
in_string = false;
}
continue;
}
match byte {
b'"' => in_string = true,
b'[' | b'{' => {
depth += 1;
if depth > limit {
return true;
}
}
b']' | b'}' => depth = depth.saturating_sub(1),
_ => {}
}
}
false
}

pub(crate) struct DirectParser<'a> {
input: &'a [u8],
pos: usize,
Expand Down
Loading