diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs index 785345c67..ae94feb0a 100644 --- a/crates/js/src/lib.rs +++ b/crates/js/src/lib.rs @@ -27,9 +27,11 @@ use std::cell::RefCell; use std::collections::BTreeSet; use std::sync::Arc; +use boa_engine::context::HostHooks; use boa_engine::object::builtins::{JsArray, JsFunction}; use boa_engine::object::{FunctionObjectBuilder, ObjectInitializer}; use boa_engine::property::Attribute; +use boa_engine::realm::Realm; use boa_engine::{Context, JsError, JsNativeError, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string}; /// The kinds of field, as `field.type` names them. @@ -1128,8 +1130,46 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc JsResult<()> { + Err(JsNativeError::typ().with_message("scripts can't compile code from strings (eval, Function)").into()) + } +} + +/// Host hooks that hold code compiled from strings to the checks a script passes ([`refuse`]), +/// for runners that start the script itself through `eval`. +pub(crate) struct CheckedCodeFromStrings; + +impl HostHooks for CheckedCodeFromStrings { + fn ensure_can_compile_strings(&self, _: Realm, parameters: &[JsString], body: &JsString, _: bool, _: &mut Context) -> JsResult<()> { + let units = parameters.iter().chain(std::iter::once(body)).fold(0usize, |n, s| n.saturating_add(s.len())); + if units > MAX_SCRIPT_BYTES { + return Err(JsNativeError::range().with_message("the code is too long to compile").into()); + } + // As one text: brackets left open in one parameter carry into the next. + let mut source = parameters.iter().map(JsString::to_std_string_lossy).collect::>().join(","); + source.push('\n'); + source.push_str(&body.to_std_string_lossy()); + match refuse(&source) { + Some(why) => Err(JsNativeError::range().with_message(why).into()), + None => Ok(()), + } + } +} + fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc>, doc_scripts: &[String], limits: Limits) -> Outcome { - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(NoCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => { + let error = Some(format!("the script engine could not start: {e}")); + return Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), error, ..Default::default() }; + } + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); ctx.insert_data(RefCell::new(Host { diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 7a9efb5df..5b6adef53 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -313,6 +313,23 @@ fn ordinary_nesting_still_runs() { assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000"); } +/// Code built from strings at run time would reach the parser without the size and nesting +/// checks the script itself passed, so `eval` and the `Function` constructors refuse it. +#[test] +fn scripts_cannot_compile_code_from_strings() { + for s in [ + "event.value = eval('1 + 1');", + "event.value = (0, eval)('1 + 1');", + "event.value = new Function('return 2')();", + "event.value = Function('a', 'return a')(2);", + "event.value = (function* () {}).constructor('yield 2')().next().value;", + "event.value = new Array(1001).join('(') + '1' + new Array(1001).join(')'); event.value = eval(event.value);", + ] { + let o = go(s, &Event::field("Calculate", "a", "")); + assert!(o.error.as_deref().is_some_and(|e| e.contains("compile code from strings")), "{s}: {o:?}"); + } +} + mod xfa_model { use crate::Limits; use crate::xfa::*; @@ -398,6 +415,22 @@ mod xfa_model { assert_eq!(o.effects, vec![XfaEffect::SetValue { som: "form1[0].page1[0].total[0]".into(), value: "5".into() }]); } + /// The script runs through `eval`, so code built from strings at run time is held to the + /// script's own size and nesting checks instead of being refused outright. + #[test] + fn code_built_from_strings_is_checked_like_the_script() { + let total = "form1[0].page1[0].total[0]"; + assert_eq!(go("eval('qty.rawValue * 2')", total, "calculate").result.as_deref(), Some("8")); + assert_eq!(go("new Function('return 3')()", total, "calculate").result.as_deref(), Some("3")); + for s in [ + "eval(new Array(1001).join('(') + '1' + new Array(1001).join(')'))", + "new Function(new Array(1001).join('[') + new Array(1001).join(']'))", + ] { + let o = go(s, total, "calculate"); + assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{s}: {o:?}"); + } + } + /// A form whose one field `d` holds the ISO date 2026-10-20, shown as 20261020. pub(super) fn dated_form() -> XfaNode { let d = XfaNode { formatted: Some("20261020".into()), ..field("d", "form1[0].page1[0].d[0]", "2026-10-20", false) }; diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index 5a693db9f..490408a10 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -1473,7 +1473,10 @@ pub(crate) fn warm_up() { /// `started` is called once the engine is ready: a time limit on the script runs from then. pub(crate) fn run_here(script: &str, event: &XfaEvent, doc: &XfaDoc, root: XfaNode, limits: Limits, started: &dyn Fn()) -> XfaOutcome { warm_up(); - let mut ctx = Context::default(); + let mut ctx = match Context::builder().host_hooks(std::rc::Rc::new(crate::CheckedCodeFromStrings)).build() { + Ok(ctx) => ctx, + Err(e) => return XfaOutcome { error: Some(format!("the script engine could not start: {e}")), ..Default::default() }, + }; ctx.runtime_limits_mut().set_loop_iteration_limit(limits.loop_iterations); ctx.runtime_limits_mut().set_recursion_limit(limits.recursion); let host_state = XHost::new(root, &event.target, doc);