Files
pdfcraft/crates/js/src/tests.rs
T
Brandon ThomasandClaude Opus 5.5 18c1acdb31 Never crash: hostile form scripts can't overflow the stack
boa's parser and compiler recurse once per nesting level and have no limit of their own: about
100 nested parentheses in a field or document script overflowed a 2 MiB stack and aborted the
app, which `catch_unwind` can't stop. `printcraft_js::run` now:
- refuses a script longer than 256 KiB, or one that nests brackets more than 64 deep, chains
  more than 64 prefix operators, or has more than 4096 `?`. Strings and comments are skipped.
  The script fails with an error, as a syntax error would;
- runs the engine on its own thread with a 1 GiB stack, which is address space, committed only
  as it's used. That covers every unbracketed construct a 256 KiB script can nest. A panic
  inside the engine becomes a script error. On wasm the script still runs inline.

Regression tests: `deeply_nested_scripts_fail_instead_of_crashing`, which aborted the test
process before this change, and `ordinary_nesting_still_runs`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:06:24 -04:00

186 lines
7.4 KiB
Rust

use super::*;
fn fields() -> Vec<FieldState> {
let mut agree = FieldState::new("agree", FieldType::CheckBox, vec![]);
agree.options = vec![("Yes".into(), "Yes".into())];
let mut size = FieldState::new("size", FieldType::ComboBox, vec!["m".into()]);
size.options = vec![("s".into(), "Small".into()), ("m".into(), "Medium".into()), ("l".into(), "Large".into())];
vec![
FieldState::new("price", FieldType::Text, vec!["12.5".into()]),
FieldState::new("qty", FieldType::Text, vec!["4".into()]),
FieldState::new("total", FieldType::Text, vec![]),
FieldState::new("zip", FieldType::Text, vec!["02134".into()]),
agree,
size,
]
}
fn doc() -> DocInfo {
DocInfo { file_name: "order.pdf".into(), num_pages: 3, page: 0, info: vec![("title".into(), "Order".into())] }
}
fn go(script: &str, event: &Event) -> Outcome {
run(script, event, &doc(), &fields(), &[], Limits::default())
}
#[test]
fn calculate_scripts_read_fields_and_set_the_value() {
let o = go("event.value = this.getField('price').value * getField('qty').value;", &Event::field("Calculate", "total", ""));
assert_eq!(o.error, None);
assert_eq!(o.value, "50");
assert!(o.rc);
}
#[test]
fn numbers_with_leading_zeros_stay_text() {
let o = go("event.value = typeof getField('zip').value + ':' + typeof getField('qty').value;", &Event::field("Calculate", "total", ""));
assert_eq!(o.value, "string:number");
}
#[test]
fn validate_scripts_reject_with_rc_and_alert() {
let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11"));
assert!(!o.rc);
assert_eq!(o.alerts, ["Too many"]);
let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3"));
assert!(o.rc);
}
#[test]
fn keystroke_scripts_see_the_change() {
let mut e = Event::field("Keystroke", "qty", "1");
e.change = "x".into();
e.will_commit = false;
let o = go("event.rc = /^[0-9]*$/.test(event.change);", &e);
assert!(!o.rc);
}
#[test]
fn scripts_change_other_fields_and_their_properties() {
let o = go(
"var t = getField('total'); t.value = 99; t.readonly = true; t.display = display.hidden; \
getField('agree').checkThisBox(0, true); getField('size').value = 'l'; t.textColor = color.red;",
&Event::field("Mouse Up", "agree", ""),
);
assert_eq!(o.error, None);
let total = o.changed.iter().find(|f| f.name == "total").unwrap();
assert_eq!(total.value, ["99"]);
assert!(total.readonly);
assert_eq!(total.display, DISPLAY_HIDDEN);
assert_eq!(total.text_color.as_deref(), Some(&["RGB".to_string(), "1".into(), "0".into(), "0".into()][..]));
assert_eq!(o.changed.iter().find(|f| f.name == "agree").unwrap().value, ["Yes"]);
assert_eq!(o.changed.iter().find(|f| f.name == "size").unwrap().value, ["l"]);
}
#[test]
fn choice_and_check_box_object_model() {
let o = go(
"var s = getField('size'); event.value = [s.numItems, s.getItemAt(2, false), s.currentValueIndices, s.valueAsString, \
getField('agree').value, getField('agree').isBoxChecked(0), s.type].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.value, "3|Large|1|m|Off|false|combobox");
}
#[test]
fn util_printf_printd_and_printx() {
let o = go(
"event.value = [util.printf('%,0.2f', 1234567.891), util.printf('%05d|%s|%x', 42, 'hi', 255), util.printf('%,2.2f', 1234.5), \
util.printd('mmm d, yyyy HH:MM', new Date(2024, 0, 5, 9, 7)), util.printd('dddd', new Date(2024, 0, 5)), \
util.printx('(999) 999-9999', '5551234567'), util.printx('>AAA', 'abc')].join('|');",
&Event::field("Calculate", "total", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.value, "1,234,567.89|00042|hi|FF|1.234,50|Jan 5, 2024 09:07|Friday|(555) 123-4567|ABC");
}
#[test]
fn document_requests_and_console() {
let o = go(
"console.println('hello ' + this.documentFileName + ' ' + numPages + ' ' + info.title); this.pageNum = 2; \
this.resetForm(['qty']); this.print(); app.launchURL('https://example.org'); this.submitForm({cURL: 'https://example.org/f'});",
&Event::field("Mouse Up", "agree", ""),
);
assert_eq!(o.error, None);
assert_eq!(o.console, ["hello order.pdf 3 Order"]);
assert_eq!(
o.requests,
[
Request::GoToPage(2),
Request::Reset(vec!["qty".into()]),
Request::Print,
Request::LaunchUrl("https://example.org".into()),
Request::Submit("https://example.org/f".into())
]
);
}
#[test]
fn document_level_functions_are_available() {
let o = run(
"event.value = double(getField('qty').value);",
&Event::field("Calculate", "total", ""),
&doc(),
&fields(),
&["function double(x) { return x * 2; }".into()],
Limits::default(),
);
assert_eq!(o.value, "8");
}
#[test]
fn errors_and_runaway_scripts_are_contained() {
let o = go("event.value = nosuch.thing;", &Event::field("Calculate", "total", "keep"));
assert!(o.error.is_some());
let o = run("while (true) {}", &Event::doc("Open"), &doc(), &fields(), &[], Limits { loop_iterations: 10_000, recursion: 64 });
assert!(o.error.is_some(), "the loop limit stops it");
let o = go("function f() { return f(); } f();", &Event::doc("Open"));
assert!(o.error.is_some(), "the recursion limit stops it");
let o = go("getField('nope').value", &Event::doc("Open"));
assert!(o.error.as_deref().unwrap_or("").contains("null") || o.error.is_some());
}
#[test]
fn the_sandbox_has_no_host_access() {
let o = go(
"event.value = [typeof require, typeof process, typeof fetch, typeof XMLHttpRequest, typeof importScripts].join(',');",
&Event::doc("Open"),
);
assert_eq!(o.value, "undefined,undefined,undefined,undefined,undefined");
}
/// Hostile nesting used to overflow the stack in boa's parser and compiler, aborting the app
/// (about 100 nested parentheses were enough on a 2 MiB stack).
#[test]
fn deeply_nested_scripts_fail_instead_of_crashing() {
let n = 100_000;
let hostile = [
format!("event.value = {}1{};", "(".repeat(n), ")".repeat(n)),
format!("var a = {}1{};", "[".repeat(n), "]".repeat(n)),
format!("{}{}", "{".repeat(n), "}".repeat(n)),
format!("var a = {}1;", "!".repeat(n)),
format!("var a = {}1;", "- ".repeat(n)),
format!("var a = {}1;", "1?1:".repeat(n)),
format!("var f = {}1;", "a=>".repeat(n)),
];
for s in &hostile {
let o = go(s, &Event::doc("Open"));
assert!(o.error.is_some(), "{}…", &s[..20]);
}
// A hostile document-level script is refused too, before the field script runs.
let o = run("event.value = 'ran';", &Event::field("Calculate", "a", ""), &doc(), &fields(), &[hostile[0].clone()], Limits::default());
assert!(o.error.is_some());
}
#[test]
fn ordinary_nesting_still_runs() {
let n = 40;
let s = format!("event.value = {}1{} + [[[2]]][0][0][0] + (1 ? 2 : 3);", "(".repeat(n), ")".repeat(n));
let o = go(&s, &Event::field("Calculate", "a", ""));
assert_eq!(o.error, None);
assert_eq!(o.value, "5");
// Brackets in strings and comments don't count.
let s = format!("// {}\nevent.value = '{}'.length;", "(".repeat(1000), "[".repeat(1000));
assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000");
}