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.
This commit is contained in:
aiongg
2026-10-22 17:22:50 +00:00
parent a5cfc3eec4
commit 72fd2a1b24
3 changed files with 129 additions and 13 deletions
+5 -3
View File
@@ -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<String> = 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(())
+50
View File
@@ -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]
+74 -10
View File
@@ -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<String> {
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::<String>())),
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<JsV
fn reset_data(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let list = text(&arg(args, 0), ctx)?;
let names: Vec<String> = 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())
}