From 72fd2a1b24bdbeff51421165bbcb1230bf3da48b Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:18:45 +0000 Subject: [PATCH] fix(js): bound the text an XFA script's effects and values hold The XFA script host capped the number of effects but not their size: URLs, focus targets and resetData lists were kept whole, a resetData list kept every repeated name, and values written to fields were each cut but not counted together. A loop repeating one long script-built string kept a copy per effect or field. URLs, focus targets and reset names are cut to a message's length, reset lists name each field once, and every effect, console line and value written charges a byte budget for the run; past it they are left out and noted. FormCalc charges an assignment to many fields once per field copied. --- crates/js/src/formcalc.rs | 8 ++-- crates/js/src/tests.rs | 50 +++++++++++++++++++++++ crates/js/src/xfa.rs | 84 ++++++++++++++++++++++++++++++++++----- 3 files changed, 129 insertions(+), 13 deletions(-) diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index a29397339..7a3de5a70 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -15,7 +15,7 @@ use std::rc::Rc; use crate::Limits; use crate::xfa::{ HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances, - resolve_som, run_within, + reset_names, resolve_som, run_within, }; /// Deepest nesting the parser follows. @@ -1466,8 +1466,7 @@ impl Interp<'_> { } "resetdata" => { let list = self.arg_str(args, 0)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - self.h.push(XfaEffect::ResetData(names)); + self.h.push(XfaEffect::ResetData(reset_names(&list))); Ok(Val::Null) } "print" => { @@ -1646,6 +1645,8 @@ impl Interp<'_> { return Err(format!("{som} has no {other} to assign to")); } for k in kids { + // Every destination copies the value: charge each copy. + self.charge(self.cost(&v))?; self.assign_to(Val::Node(k), "rawValue", v.clone())?; } } @@ -1654,6 +1655,7 @@ impl Interp<'_> { } Val::List(items) => { for it in items { + self.charge(self.cost(&v))?; self.assign_to(it, prop, v.clone())?; } Ok(()) diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 07ae8ecd0..8ce338b2e 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -665,6 +665,22 @@ mod xfa_model { assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097)); } + #[test] + fn urls_and_reset_lists_are_clipped_and_deduplicated() { + let o = go( + "var s = 'x'; while (s.length < 65536) s += s; for (var i = 0; i < 600; i++) xfa.host.gotoURL(s + i); \ + var r = 'a,'; while (r.length < 262144) r += r; xfa.host.resetData(r);", + "form1[0].page1[0]", + "initialize", + ); + assert_eq!(o.error, None); + let urls: Vec<&String> = o.effects.iter().filter_map(|e| if let XfaEffect::LaunchUrl(u) = e { Some(u) } else { None }).collect(); + assert_eq!(urls.len(), 600); + assert!(urls.iter().all(|u| u.chars().count() <= 4_097)); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 1); + } + #[test] fn a_slow_engine_start_does_not_count_against_the_script() { let ms = std::time::Duration::from_millis; @@ -716,6 +732,40 @@ mod formcalc { o.result.unwrap_or_default() } + /// Assigning one long value to many fields at once stops at the run's byte budget and is + /// charged per copy, and a reset list names each field once. + #[test] + fn bulk_assignments_and_reset_lists_are_bounded() { + let f = |i: usize| super::xfa_model::field("f", &format!("form1[0].page1[0].f[{i}]"), "", false); + let root = XfaNode { + name: "form1".into(), + som: "form1[0]".into(), + kind: Some(XfaKind::Subform), + children: vec![XfaNode { + name: "page1".into(), + som: "form1[0].page1[0]".into(), + kind: Some(XfaKind::Subform), + children: (0..1300).map(f).collect(), + ..Default::default() + }], + ..Default::default() + }; + let ev = XfaEvent { activity: "initialize".into(), target: "form1[0]".into(), ..Default::default() }; + let run = |script: &str| run_formcalc(script, &ev, &XfaDoc::default(), &root, Limits::default()); + let o = run("var s = \"x\"\nwhile (Len(s) < 65536) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error, None, "{o:?}"); + let kept: usize = o.effects.iter().map(|e| if let XfaEffect::SetValue { value, .. } = e { value.len() } else { 0 }).sum(); + assert!(kept <= MAX_PAYLOAD, "{kept} bytes of values kept"); + assert!(o.notes.iter().any(|n| n.contains("left out")), "{:?}", o.notes); + // 1300 copies of a 1 MiB value are more work than a script may do. + let o = run("var s = \"x\"\nwhile (Len(s) < 1048576) do s = Concat(s, s) endwhile\npage1.f = s"); + assert_eq!(o.error.as_deref(), Some("the script ran too long"), "{:?}", o.notes); + let o = run("var r = \"a,\"\nwhile (Len(r) < 262144) do r = Concat(r, r) endwhile\n$host.resetData(r)\n$host.resetData(r)"); + assert_eq!(o.error, None, "{o:?}"); + let names: usize = o.effects.iter().map(|e| if let XfaEffect::ResetData(n) = e { n.len() } else { 0 }).sum(); + assert_eq!(names, 2); + } + /// FormCalc reads `formattedValue` as the field's picture shows it too, until the script /// sets the value. #[test] diff --git a/crates/js/src/xfa.rs b/crates/js/src/xfa.rs index e875023d6..ae38658e0 100644 --- a/crates/js/src/xfa.rs +++ b/crates/js/src/xfa.rs @@ -8,7 +8,7 @@ //! children, then its siblings, then each ancestor's children. use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use boa_engine::object::ObjectInitializer; use boa_engine::object::builtins::{JsArray, JsProxy}; @@ -182,6 +182,36 @@ pub(crate) fn clip_value(v: String) -> (String, bool) { } } +/// The field names of a `resetData` list (comma-separated), each cut to [`MAX_LINE`] and +/// named once, at most as many as a snapshot holds nodes. +pub(crate) fn reset_names(list: &str) -> Vec { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) { + if out.len() >= MAX_NODES { + break; + } + let name = clip(name.to_string()); + if seen.insert(name.clone()) { + out.push(name); + } + } + out +} + +/// Bytes of text `e` holds. +fn payload(e: &XfaEffect) -> usize { + match e { + XfaEffect::SetValue { som, value: s } | XfaEffect::SetPresence { som, presence: s } | XfaEffect::SetAccess { som, access: s } => { + som.len().saturating_add(s.len()) + } + XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(), + XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(), + XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::())), + XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout => 0, + } +} + /// A script's completion value (what a calculate script puts in its field) cut to /// [`MAX_VALUE`] characters, as values a script sets are, with a note when it was. pub(crate) fn clip_result(out: &mut XfaOutcome) { @@ -235,6 +265,9 @@ pub(crate) struct XHost { clipped_values: usize, dropped_alerts: usize, dropped_console: usize, + /// Bytes of text kept in effects, console lines and values written, against [`MAX_PAYLOAD`]. + kept: usize, + dropped_bytes: usize, } impl XHost { @@ -259,15 +292,34 @@ impl XHost { clipped_values: 0, dropped_alerts: 0, dropped_console: 0, + kept: 0, + dropped_bytes: 0, + } + } + + /// Room for `bytes` more bytes of kept text: charged when there is, a drop counted when not. + fn room(&mut self, bytes: usize) -> bool { + match self.kept.checked_add(bytes).filter(|k| *k <= MAX_PAYLOAD) { + Some(k) => { + self.kept = k; + true + } + None => { + self.dropped_bytes = self.dropped_bytes.saturating_add(1); + false + } } } /// Record an effect: values, presence and access set again on the same object replace the - /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes) effects are counted - /// and dropped. + /// earlier one; past [`MAX_EFFECTS`] (or [`MAX_ALERTS`] message boxes, or [`MAX_PAYLOAD`] + /// bytes of text) effects are counted and dropped. pub(crate) fn push(&mut self, e: XfaEffect) { let e = match e { XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)), + XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)), + XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)), + XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()), XfaEffect::SetValue { som, value } => { let (value, cut) = clip_value(value); if cut { @@ -277,6 +329,9 @@ impl XHost { } other => other, }; + if !self.room(payload(&e)) { + return; + } let key = match &e { XfaEffect::SetValue { som, .. } => Some((0u8, som.clone())), XfaEffect::SetPresence { som, .. } => Some((1, som.clone())), @@ -319,7 +374,7 @@ impl XHost { pub(crate) fn print(&mut self, line: String) { if self.console.len() >= MAX_CONSOLE { self.dropped_console += 1; - } else { + } else if self.room(line.len()) { self.console.push(line); } } @@ -336,6 +391,9 @@ impl XHost { if self.dropped_console > 0 { out.push(format!("the script printed more than {MAX_CONSOLE} console lines; {} more were left out", self.dropped_console)); } + if self.dropped_bytes > 0 { + out.push(format!("the script's changes and output passed {} MiB of text; {} more were left out", MAX_PAYLOAD >> 20, self.dropped_bytes)); + } if self.clipped_values > 0 { out.push(format!("the script set {} value(s) longer than {MAX_VALUE} characters; they were cut", self.clipped_values)); } @@ -346,19 +404,26 @@ impl XHost { /// holds a value; writing one into a subform would put text where another viewer expects /// a data group). Returns whether it was. pub(crate) fn set_value(&mut self, i: usize, text: String) -> bool { - let Some(n) = self.nodes.get_mut(i) else { return false }; + let Some(n) = self.nodes.get(i) else { return false }; if n.gone || !matches!(n.kind, XfaKind::Field | XfaKind::ExclGroup) { return false; } + let som = n.som.clone(); // Cut here, not only in the effect: later reads in the script see what is saved, and // the snapshot stays bounded however often a long value is written. let (text, cut) = clip_value(text); - n.value = text.clone(); - n.formatted = None; - let som = n.som.clone(); if cut { self.clipped_values += 1; } + // The snapshot's copy counts as well as the effect's: past the budget the write is left + // out of both. + if !self.room(text.len()) { + return true; + } + if let Some(n) = self.nodes.get_mut(i) { + n.value = text.clone(); + n.formatted = None; + } self.push(XfaEffect::SetValue { som, value: text }); true } @@ -1027,8 +1092,7 @@ fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult JsResult { let list = text(&arg(args, 0), ctx)?; - let names: Vec = list.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect(); - effect(ctx, XfaEffect::ResetData(names))?; + effect(ctx, XfaEffect::ResetData(reset_names(&list)))?; Ok(JsValue::undefined()) }