fix(js): refuse code compiled from strings that skipped the script checks

eval and the Function constructors compiled code built at run time, which never
passed the size and nesting checks a script passes before it runs, so a short
form script could hand boa's recursive parser a deeply nested string and
overflow the stack. Form scripts now refuse to compile code from strings. XFA
scripts, which the runner itself starts through eval, hold such code to the
same checks as the script.
This commit is contained in:
aiongg
2026-10-22 13:18:26 -04:00
committed by Dylan M. Taylor
parent d5047aa4ac
commit a525cf1d42
3 changed files with 78 additions and 2 deletions
+41 -1
View File
@@ -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<Vec<Fi
run_here(script, event, doc, fields, doc_scripts, limits)
}
/// Host hooks that refuse to compile code from strings (`eval`, `Function` and its generator
/// and async kin). Such code is built at run time, so it never passed [`refuse`], and boa's
/// recursive parser would overflow the stack on a deeply nested one.
struct NoCodeFromStrings;
impl HostHooks for NoCodeFromStrings {
fn ensure_can_compile_strings(&self, _: Realm, _: &[JsString], _: &JsString, _: bool, _: &mut Context) -> 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::<Vec<_>>().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<Vec<FieldState>>, 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 {
+33
View File
@@ -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) };
+4 -1
View File
@@ -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);