fix(js): cap a form script's alerts, console lines and requests

The AcroForm script host kept every alert, console line and request a
script made, each as long as the script made it. A loop printing a long
script-built string held a copy per iteration. The host applies the XFA
host's limits: message and console counts, the effect count for requests,
each message, URL and field name cut to a line, and a byte budget across
all of them, with a closing console line saying how many were left out.
This commit is contained in:
aiongg
2026-10-22 17:22:50 +00:00
parent 2e9b247f3f
commit a5cfc3eec4
3 changed files with 124 additions and 14 deletions
+91 -13
View File
@@ -203,6 +203,66 @@ struct Host {
console: Vec<String>,
requests: Vec<Request>,
doc: DocInfo,
/// Bytes of text kept in alerts, console lines and requests, against [`xfa::MAX_PAYLOAD`].
kept: usize,
/// Alerts, console lines and requests the limits left out.
dropped: usize,
}
impl Host {
/// 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 <= xfa::MAX_PAYLOAD) {
Some(k) => {
self.kept = k;
true
}
None => {
self.dropped = self.dropped.saturating_add(1);
false
}
}
}
fn alert(&mut self, m: String) {
if self.alerts.len() >= xfa::MAX_ALERTS {
self.dropped = self.dropped.saturating_add(1);
} else if self.room(m.len()) {
self.alerts.push(m);
}
}
fn print(&mut self, m: String) {
if self.console.len() >= xfa::MAX_CONSOLE {
self.dropped = self.dropped.saturating_add(1);
} else if self.room(m.len()) {
self.console.push(m);
}
}
/// Record `r`, its names and URLs cut to a message's length, up to [`xfa::MAX_EFFECTS`]
/// requests and the run's byte budget.
fn request(&mut self, r: Request) {
if self.requests.len() >= xfa::MAX_EFFECTS {
self.dropped = self.dropped.saturating_add(1);
return;
}
let r = match r {
Request::Reset(names) => Request::Reset(names.into_iter().map(xfa::clip).collect()),
Request::LaunchUrl(u) => Request::LaunchUrl(xfa::clip(u)),
Request::Submit(u) => Request::Submit(xfa::clip(u)),
Request::Focus(n) => Request::Focus(xfa::clip(n)),
other => other,
};
let bytes = match &r {
Request::Reset(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
Request::LaunchUrl(u) | Request::Submit(u) | Request::Focus(u) => u.len(),
_ => 0,
};
if self.room(bytes) {
self.requests.push(r);
}
}
}
type Shared = RefCell<Host>;
@@ -223,6 +283,19 @@ fn text(v: &JsValue, ctx: &mut Context) -> JsResult<String> {
Ok(v.to_string(ctx)?.to_std_string_escaped())
}
/// [`text`] cut to a message's length ([`xfa::clip`]), without first copying all of a longer
/// string.
fn text_line(v: &JsValue, ctx: &mut Context) -> JsResult<String> {
if v.is_undefined() || v.is_null() {
return Ok(String::new());
}
let js = v.to_string(ctx)?;
// Two UTF-16 units per character at most: the clip still sees more than a line's worth.
let keep = xfa::MAX_LINE.saturating_add(1).saturating_mul(2);
let js = if js.len() > keep { js.get(..keep).unwrap_or_default() } else { js };
Ok(xfa::clip(js.to_std_string_escaped()))
}
fn arg(args: &[JsValue], i: usize) -> JsValue {
args.get(i).cloned().unwrap_or_default()
}
@@ -440,7 +513,7 @@ fn field_object(ctx: &mut Context, name: &str) -> JsObject {
Ok(JsValue::undefined())
}
fn set_focus(_: &JsValue, _: &[JsValue], name: &JsString, c: &mut Context) -> JsResult<JsValue> {
host(c)?.borrow_mut().requests.push(Request::Focus(name.to_std_string_escaped()));
host(c)?.borrow_mut().request(Request::Focus(name.to_std_string_escaped()));
Ok(JsValue::undefined())
}
@@ -494,12 +567,12 @@ fn get_nth_field_name(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsRes
fn reset_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let names = strings(&arg(args, 0), ctx)?;
host(ctx)?.borrow_mut().requests.push(Request::Reset(names));
host(ctx)?.borrow_mut().request(Request::Reset(names));
Ok(JsValue::undefined())
}
fn print(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
host(ctx)?.borrow_mut().requests.push(Request::Print);
host(ctx)?.borrow_mut().request(Request::Print);
Ok(JsValue::undefined())
}
@@ -512,7 +585,7 @@ fn submit_form(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsV
}
_ => text(&a, ctx)?,
};
host(ctx)?.borrow_mut().requests.push(Request::Submit(url));
host(ctx)?.borrow_mut().request(Request::Submit(url));
Ok(JsValue::undefined())
}
@@ -534,38 +607,38 @@ fn page_num_set(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<Js
let last = h.doc.num_pages.saturating_sub(1);
let p = (n.max(0.0) as usize).min(last);
h.doc.page = p;
h.requests.push(Request::GoToPage(p));
h.request(Request::GoToPage(p));
Ok(JsValue::undefined())
}
// ── app, util, console ──────────────────────────────────────────────────────────────────────
/// A message argument: a string, or an object with `cMsg`.
/// A message argument: a string, or an object with `cMsg`, cut to a message's length.
fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<String> {
match v.as_object() {
Some(o) if !o.is_array() && !o.is_callable() => {
let m = o.get(JsString::from(key), ctx)?;
text(&m, ctx)
text_line(&m, ctx)
}
_ => text(v, ctx),
_ => text_line(v, ctx),
}
}
fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let m = message(&arg(args, 0), "cMsg", ctx)?;
host(ctx)?.borrow_mut().alerts.push(m);
host(ctx)?.borrow_mut().alert(m);
// The OK button.
Ok(JsValue::from(1))
}
fn beep(_: &JsValue, _: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
host(ctx)?.borrow_mut().requests.push(Request::Beep);
host(ctx)?.borrow_mut().request(Request::Beep);
Ok(JsValue::undefined())
}
fn launch_url(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let u = message(&arg(args, 0), "cURL", ctx)?;
host(ctx)?.borrow_mut().requests.push(Request::LaunchUrl(u));
host(ctx)?.borrow_mut().request(Request::LaunchUrl(u));
Ok(JsValue::undefined())
}
@@ -575,8 +648,8 @@ fn response(_: &JsValue, _: &[JsValue], _: &mut Context) -> JsResult<JsValue> {
}
fn println(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let m = text(&arg(args, 0), ctx)?;
host(ctx)?.borrow_mut().console.push(m);
let m = text_line(&arg(args, 0), ctx)?;
host(ctx)?.borrow_mut().print(m);
Ok(JsValue::undefined())
}
@@ -1187,6 +1260,8 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldSta
console: Vec::new(),
requests: Vec::new(),
doc: doc.clone(),
kept: 0,
dropped: 0,
}));
let mut out = Outcome { rc: true, value: event.value.clone(), change: event.change.clone(), ..Default::default() };
let result = (|| -> JsResult<()> {
@@ -1217,6 +1292,9 @@ fn run_here(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<FieldSta
out.alerts = h.alerts;
out.console = h.console;
out.requests = h.requests;
if h.dropped > 0 {
out.console.push(format!("the script's messages, console lines and requests passed their limits; {} more were left out", h.dropped));
}
}
out
}
+29
View File
@@ -244,6 +244,35 @@ fn document_requests_and_console() {
);
}
/// Bytes of text a run handed back in its messages, console lines and requests.
fn kept_bytes(o: &Outcome) -> usize {
let req = |r: &Request| match r {
Request::Reset(names) => names.iter().map(String::len).sum(),
Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(),
_ => 0,
};
o.console.iter().chain(&o.alerts).map(String::len).sum::<usize>() + o.requests.iter().map(req).sum::<usize>()
}
#[test]
fn console_alerts_and_requests_are_capped_in_count_and_bytes() {
let o = go(
"var s = 'x'; while (s.length < 8192) s += s; \
for (var i = 0; i < 1500; i++) { console.println(s); app.alert(s); } \
var a = []; for (var i = 0; i < 2000; i++) a.push(s); \
for (var i = 0; i < 5; i++) this.resetForm(a); \
for (var i = 0; i < 12000; i++) app.launchURL('u');",
&Event::field("Mouse Up", "agree", ""),
);
assert_eq!(o.error, None);
assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len());
assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len());
assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len());
assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097));
assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o));
assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last());
}
#[test]
fn printd_reads_a_long_literal_run_in_linear_work() {
crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed);
+4 -1
View File
@@ -158,7 +158,10 @@ pub const MAX_ALERTS: usize = 100;
/// Most console lines one run may print.
pub const MAX_CONSOLE: usize = 1_000;
/// Longest message or console line kept, in characters.
const MAX_LINE: usize = 4_096;
pub(crate) const MAX_LINE: usize = 4_096;
/// Most bytes of text one run may hand back or write: messages, console lines, URLs, names
/// and the values it sets. Past it, more are left out and noted.
pub(crate) const MAX_PAYLOAD: usize = 32 << 20;
/// Longest value a script may put in a field (characters); longer ones are cut and noted.
const MAX_VALUE: usize = 65_536;