mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 17:01:23 +00:00
fix(js): follow every reading of a script in the nesting guard, so a regex can't put it out of phase (#5079)
## What this changes
The nesting guard that keeps hostile scripts from overflowing the stack in boa's parser could be put out of phase, and this replaces it with one that can't be. boa parses by recursive descent with no depth limit of its own (boa_parser 0.22 has none to turn on), and a stack overflow aborts the process, since `catch_unwind` can't catch it.
**The problem.** `refuse()` scanned bytes and guessed where strings begin. A quote inside a regular expression put it out of phase with the real strings. The first version of this PR counted opening brackets inside skipped text, which closed the reported `/'/` reproduction. It still let closing brackets inside *real* strings count as code. `x=` followed by 5,000 lines of `(/"/,"))))",` nests 5,000 deep in 65 KB (under the 256 KiB limit), while that scan never went past depth 1. The same works with `'`, and with template literals, which the scan didn't read at all.
**The fix.** The guard now lives in `crates/js/src/nesting.rs`.
- **It doesn't guess.** Whether a `/` is a division or starts a regular expression depends on where the parser is: boa's parser, not its lexer, sets that. So the scan follows every reading at once. At each `/` that isn't a comment, it splits into a division reading and a regular-expression reading, and counts nesting on each. boa's reading is always one of them, so the deepest count is at least boa's real depth. That makes it a sound bound, not a mitigation for one reproduction.
- **It drops readings boa rejects,** at the byte where boa stops with a syntax error: a string or regular expression running into a line break, or a regular expression whose groups don't match. boa runs `regress::try_parse` on each literal as it lexes it. This keeps ordinary divisions such as `Math.round(a / b * 100) / 100` from leaving stray readings that add up to false depth.
- **It reads the rest of the lexical grammar that changes what's code:** strings with escapes and line continuations, comments, template text and `${}` substitutions (which count as a level), the Annex B `<!--` and `-->` comments (followed both ways), and a leading `#!`.
- **It caps the work.** At most 16 readings are followed at once. Readings in the same place are merged first, keeping the larger counts, and past that the script is refused.
The limits are unchanged: 64 brackets, a run of 64 prefix operators, 4,096 conditionals. Every path that hands source to boa goes through `refuse()`: AcroForm scripts, XFA JavaScript, and `CheckedCodeFromStrings`.
Brackets inside strings and comments no longer count at all. That drops the trade-off in this PR's first version, which refused a script whose strings held more than 64 opening brackets in total.
Fixes #5079
## How you verified it
Locally, only formatting was run. I didn't build or test this commit, and I'm relying on CI for the build, clippy and the tests.
- [x] `cargo fmt --check -p pdfcraft-js` passes.
- [ ] clippy with `-D warnings` on `pdfcraft-js`: not run locally.
- [ ] `cargo test -p pdfcraft-js`: not run locally. A local test build was stopped before it compiled anything, so I have no local results for this commit.
What the tests check:
- `a_quote_in_a_regex_cannot_put_the_guard_out_of_phase`: the 5,000-line bypass above, and the same with `'` and backticks. It also runs a script nested exactly to the limit, closed properly.
- `nesting_guard_reads_templates_regexes_and_comments` and `divisions_do_not_add_up_to_a_refusal`: controls that must run.
- `boa_rejects_what_the_nesting_guard_drops`: the boa behaviour the guard relies on.
- The XFA-open test (`session::a_quote_in_a_regex_cannot_hide_nesting_from_an_initialize_script`) now runs the 5,000-line bypass too.
- `ordinary_nesting_still_runs` again expects 1,000 brackets inside strings and comments to run.
Not yet shown: that these tests fail on the previous head (439f86220). By reading the previous scan, the `(/"/,"))))",` script never goes past depth 1 there, but I haven't run that.
## Anything a reviewer should look at first
- `boa_rejects_what_the_nesting_guard_drops` pins the assumptions behind dropping a reading. If a boa upgrade starts accepting one of those inputs, that test fails, and the guard has to keep that reading.
- Out of scope: recursion that isn't bracket nesting, prefix runs or conditionals, such as long `a=a=a=…` chains or `if(1)if(1)…` statements. The guard didn't count those before this PR either. They're worth a separate issue once someone checks whether any of them overflows the stack within 256 KiB.
<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->
## Summary by CodeRabbit
* **Bug Fixes**
* Scripts that exceed supported nesting limits are rejected with a “nested too deeply” error, including excessive bracket nesting, prefix operators, and conditional expressions.
* Nesting checks now account for strings, comments, templates, regular expressions, and other JavaScript syntax, avoiding incorrect results from bracket-like text or ambiguous slash characters.
* After a nesting-related refusal during initialization, the session remains usable.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
e51654bc83
commit
495cba8efc
4 files changed
+496
-74
No files matched your search
@@ -39,6 +39,7 @@ mod format;
|
||||
mod globals;
|
||||
mod host;
|
||||
mod model;
|
||||
mod nesting;
|
||||
mod run;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
//! The nesting guard: whether a script nests deeper than boa's parser can take.
|
||||
//!
|
||||
//! boa parses by recursive descent with no depth limit of its own, and a stack overflow aborts
|
||||
//! the process (it is not a panic, so `catch_unwind` can't stop it). So every script is scanned
|
||||
//! before boa sees it, and one that nests too deeply is refused.
|
||||
//!
|
||||
//! A scan has to know which brackets are code, and that depends on where strings, templates,
|
||||
//! comments and regular expressions begin and end. Most of that follows from the bytes, but a
|
||||
//! `/` that isn't a comment is a division or the start of a regular expression depending on
|
||||
//! where the parser is, which a scan can't know. A scan that guesses can be put out of phase:
|
||||
//! after `/'/`, the quote in the regular expression made the real nesting that followed look
|
||||
//! like string text (#5079). So the scan doesn't guess. It follows every reading at once,
|
||||
//! splitting at each such `/` into a division and a regular expression, and counts nesting on
|
||||
//! each reading. boa's reading is always one of them, so the deepest is at least as deep as
|
||||
//! boa's. A reading boa would reject is dropped at the byte where boa stops with a syntax error
|
||||
//! (a string or regular expression running into a line break, a regular expression's groups not
|
||||
//! matching), which keeps divisions from leaving stray readings behind.
|
||||
|
||||
/// Deepest nesting of `(`, `[`, `{` and template substitutions (`${`).
|
||||
pub(crate) const MAX_BRACKET_DEPTH: usize = 64;
|
||||
|
||||
/// Longest run of prefix operators (`!`, `~`, `+`, `-`).
|
||||
pub(crate) const MAX_PREFIX_RUN: usize = 64;
|
||||
|
||||
/// Most `?` (conditional expressions nest without brackets).
|
||||
pub(crate) const MAX_CONDITIONALS: usize = 4096;
|
||||
|
||||
/// Most readings followed at once. Readings that differ only in their counts are merged first,
|
||||
/// keeping the larger counts; past this, the script is refused.
|
||||
const MAX_READINGS: usize = 16;
|
||||
|
||||
/// Where a reading is in the script.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
enum Mode {
|
||||
Code,
|
||||
/// In a string that this quote closes.
|
||||
Str(u8),
|
||||
/// In a template's text: after its backtick, or after a substitution's closing `}`.
|
||||
Template,
|
||||
/// In a regular expression literal: in a class (`[...]`), and the groups open outside one.
|
||||
Regex {
|
||||
class: bool,
|
||||
groups: usize,
|
||||
},
|
||||
/// In a `//`, `<!--` or `-->` comment, to the end of the line.
|
||||
LineComment,
|
||||
/// In a `/* */` comment.
|
||||
BlockComment,
|
||||
}
|
||||
|
||||
/// One way of reading the script, and what it has counted so far.
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
|
||||
struct Reading {
|
||||
mode: Mode,
|
||||
/// Bytes to pass over: the rest of a two-byte token, or what a backslash escapes.
|
||||
skip: usize,
|
||||
/// For each open template substitution, the braces open inside it, so the `}` that closes
|
||||
/// the substitution is told from one that closes a block.
|
||||
substitutions: Vec<usize>,
|
||||
depth: usize,
|
||||
prefix: usize,
|
||||
conditionals: usize,
|
||||
}
|
||||
|
||||
impl Reading {
|
||||
fn in_code() -> Self {
|
||||
Self { mode: Mode::Code, skip: 0, substitutions: Vec::new(), depth: 0, prefix: 0, conditionals: 0 }
|
||||
}
|
||||
|
||||
fn with_mode(&self, mode: Mode) -> Self {
|
||||
Self { mode, ..self.clone() }
|
||||
}
|
||||
|
||||
fn over_limit(&self) -> bool {
|
||||
self.depth > MAX_BRACKET_DEPTH || self.prefix > MAX_PREFIX_RUN || self.conditionals > MAX_CONDITIONALS
|
||||
}
|
||||
|
||||
fn open(&mut self) {
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
self.prefix = 0;
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
self.prefix = 0;
|
||||
}
|
||||
|
||||
/// Read byte `i` of `b`. A second reading that splits off here goes to `forks`. Returns
|
||||
/// false when boa would stop with a syntax error at this byte, ending this reading.
|
||||
fn advance(&mut self, b: &[u8], i: usize, forks: &mut Vec<Reading>) -> bool {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return true;
|
||||
}
|
||||
let Some(&c) = b.get(i) else { return true };
|
||||
match self.mode {
|
||||
Mode::Code => {
|
||||
self.code(c, b, i, forks);
|
||||
true
|
||||
}
|
||||
Mode::Str(quote) => self.string(quote, c, b, i),
|
||||
Mode::Template => {
|
||||
self.template(c, b, i);
|
||||
true
|
||||
}
|
||||
Mode::Regex { class, groups } => self.regex(class, groups, c, b, i),
|
||||
Mode::LineComment => {
|
||||
let n = line_break(b, i);
|
||||
if n > 0 {
|
||||
self.mode = Mode::Code;
|
||||
self.skip = n - 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
Mode::BlockComment => {
|
||||
if c == b'*' && at(b, i + 1, b"/") {
|
||||
self.mode = Mode::Code;
|
||||
self.skip = 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn code(&mut self, c: u8, b: &[u8], i: usize, forks: &mut Vec<Reading>) {
|
||||
match c {
|
||||
b'\'' | b'"' => {
|
||||
self.mode = Mode::Str(c);
|
||||
self.prefix = 0;
|
||||
}
|
||||
b'`' => {
|
||||
self.mode = Mode::Template;
|
||||
self.prefix = 0;
|
||||
}
|
||||
b'/' if at(b, i + 1, b"/") => self.mode = Mode::LineComment,
|
||||
b'/' if at(b, i + 1, b"*") => {
|
||||
self.mode = Mode::BlockComment;
|
||||
self.skip = 1;
|
||||
}
|
||||
b'/' => {
|
||||
// A division, or the start of a regular expression: the parser decides, so
|
||||
// follow both.
|
||||
forks.push(self.with_mode(Mode::Regex { class: false, groups: 0 }));
|
||||
self.prefix = 0;
|
||||
}
|
||||
b'(' | b'[' => self.open(),
|
||||
b'{' => {
|
||||
if let Some(open) = self.substitutions.last_mut() {
|
||||
*open = open.saturating_add(1);
|
||||
}
|
||||
self.open();
|
||||
}
|
||||
b')' | b']' => self.close(),
|
||||
b'}' => {
|
||||
self.close();
|
||||
if self.substitutions.last() == Some(&0) {
|
||||
self.substitutions.pop();
|
||||
self.mode = Mode::Template;
|
||||
} else if let Some(open) = self.substitutions.last_mut() {
|
||||
*open = open.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
b'!' | b'~' | b'+' | b'-' => {
|
||||
// `-->` begins a comment at the start of a line (Annex B); followed anywhere.
|
||||
if c == b'-' && at(b, i + 1, b"->") {
|
||||
forks.push(self.with_mode(Mode::LineComment));
|
||||
}
|
||||
self.prefix = self.prefix.saturating_add(1);
|
||||
}
|
||||
b'?' => {
|
||||
self.conditionals = self.conditionals.saturating_add(1);
|
||||
self.prefix = 0;
|
||||
}
|
||||
// `<!--` begins a comment (Annex B, in scripts); followed both ways.
|
||||
b'<' if at(b, i + 1, b"!--") => {
|
||||
forks.push(self.with_mode(Mode::LineComment));
|
||||
self.prefix = 0;
|
||||
}
|
||||
// Whitespace and line breaks, and any non-ASCII byte, which may be part of either,
|
||||
// leave a run of prefix operators going.
|
||||
_ if c.is_ascii_whitespace() || !c.is_ascii() => {}
|
||||
_ => self.prefix = 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// In a string closed by `quote`. False at a line break boa rejects.
|
||||
fn string(&mut self, quote: u8, c: u8, b: &[u8], i: usize) -> bool {
|
||||
if c == quote {
|
||||
self.mode = Mode::Code;
|
||||
self.prefix = 0;
|
||||
} else if c == b'\\' {
|
||||
// An escape, or a line continuation (`\` and a line break, CR LF included).
|
||||
self.skip = line_break(b, i + 1).max(1);
|
||||
} else if c == b'\n' || c == b'\r' {
|
||||
// U+2028 and U+2029 may stand in a string; LF and CR may not.
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn template(&mut self, c: u8, b: &[u8], i: usize) {
|
||||
if c == b'`' {
|
||||
self.mode = Mode::Code;
|
||||
self.prefix = 0;
|
||||
} else if c == b'\\' {
|
||||
self.skip = 1;
|
||||
} else if c == b'$' && at(b, i + 1, b"{") {
|
||||
self.substitutions.push(0);
|
||||
self.open();
|
||||
self.mode = Mode::Code;
|
||||
self.skip = 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// In a regular expression literal. False where boa rejects it: a line break, or groups
|
||||
/// that don't match (boa checks the pattern as it reads the literal).
|
||||
fn regex(&mut self, class: bool, groups: usize, c: u8, b: &[u8], i: usize) -> bool {
|
||||
if line_break(b, i) > 0 || (c == b'\\' && line_break(b, i + 1) > 0) {
|
||||
return false;
|
||||
}
|
||||
match (c, class) {
|
||||
(b'\\', _) => self.skip = 1,
|
||||
(b']', true) => self.mode = Mode::Regex { class: false, groups },
|
||||
(b'[', false) => self.mode = Mode::Regex { class: true, groups },
|
||||
(b'(', false) => self.mode = Mode::Regex { class, groups: groups.saturating_add(1) },
|
||||
(b')', false) if groups == 0 => return false,
|
||||
(b')', false) => self.mode = Mode::Regex { class, groups: groups - 1 },
|
||||
(b'/', false) if groups > 0 => return false,
|
||||
(b'/', false) => {
|
||||
self.mode = Mode::Code;
|
||||
self.prefix = 0;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` stands in `b` at `i`.
|
||||
fn at(b: &[u8], i: usize, s: &[u8]) -> bool {
|
||||
b.get(i..).is_some_and(|rest| rest.starts_with(s))
|
||||
}
|
||||
|
||||
/// How many bytes the line break at `i` takes: CR LF, LF, CR, U+2028 or U+2029. 0 if none.
|
||||
fn line_break(b: &[u8], i: usize) -> usize {
|
||||
let rest = b.get(i..).unwrap_or_default();
|
||||
if rest.starts_with(b"\r\n") {
|
||||
2
|
||||
} else if rest.starts_with(b"\n") || rest.starts_with(b"\r") {
|
||||
1
|
||||
} else if rest.starts_with("\u{2028}".as_bytes()) || rest.starts_with("\u{2029}".as_bytes()) {
|
||||
3
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any reading of `source` nests brackets deeper than [`MAX_BRACKET_DEPTH`], runs more
|
||||
/// than [`MAX_PREFIX_RUN`] prefix operators together, or has more than [`MAX_CONDITIONALS`]
|
||||
/// conditionals; or the script has more ambiguous readings than the scan follows.
|
||||
pub(crate) fn too_deep(source: &str) -> bool {
|
||||
let b = source.as_bytes();
|
||||
// A leading `#!` is a hashbang comment: boa parses scripts and eval code from a goal that
|
||||
// allows one. Read as code it could only be a syntax error (`#` must start a private name).
|
||||
let first = if b.starts_with(b"#!") { Mode::LineComment } else { Mode::Code };
|
||||
let mut readings = vec![Reading::in_code().with_mode(first)];
|
||||
let mut next = Vec::with_capacity(MAX_READINGS + 1);
|
||||
for i in 0..b.len() {
|
||||
let mut ended: Option<Reading> = None;
|
||||
for mut r in readings.drain(..) {
|
||||
if r.advance(b, i, &mut next) {
|
||||
next.push(r);
|
||||
} else {
|
||||
ended = Some(match ended {
|
||||
Some(e) => merged(e, &r),
|
||||
None => r,
|
||||
});
|
||||
}
|
||||
}
|
||||
if next.is_empty() {
|
||||
// Every reading ends in a syntax error here, so boa stops by this byte. Carry on as
|
||||
// code anyway, with the largest counts, rather than stop checking.
|
||||
let ended = ended.unwrap_or_else(Reading::in_code);
|
||||
next.push(Reading { mode: Mode::Code, skip: 0, ..ended });
|
||||
}
|
||||
if next.iter().any(Reading::over_limit) {
|
||||
return true;
|
||||
}
|
||||
if next.len() > 1 && !settle(&mut next) {
|
||||
return true;
|
||||
}
|
||||
std::mem::swap(&mut readings, &mut next);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `a` with the larger of each count in `a` and `b`.
|
||||
fn merged(a: Reading, b: &Reading) -> Reading {
|
||||
Reading { depth: a.depth.max(b.depth), prefix: a.prefix.max(b.prefix), conditionals: a.conditionals.max(b.conditionals), ..a }
|
||||
}
|
||||
|
||||
/// Drop duplicate readings, then merge those in the same place if there are too many. False if
|
||||
/// there are still too many to follow.
|
||||
fn settle(readings: &mut Vec<Reading>) -> bool {
|
||||
readings.sort_unstable();
|
||||
readings.dedup();
|
||||
if readings.len() > MAX_READINGS {
|
||||
// Sorted, readings in the same place are next to each other. Merging keeps the larger
|
||||
// counts, so it can only refuse more.
|
||||
readings.dedup_by(|r, kept| {
|
||||
let same = r.mode == kept.mode && r.skip == kept.skip && r.substitutions == kept.substitutions;
|
||||
if same {
|
||||
*kept = merged(kept.clone(), r);
|
||||
}
|
||||
same
|
||||
});
|
||||
}
|
||||
readings.len() <= MAX_READINGS
|
||||
}
|
||||
+3
-72
@@ -19,86 +19,17 @@ pub(crate) const MAX_ARRAY_LENGTH: u64 = 1 << 20;
|
||||
/// Maximum calculated field scripts evaluated by `calculateNow()` during one JavaScript run.
|
||||
pub(crate) const MAX_CALCULATE_STEPS: usize = 4096;
|
||||
|
||||
/// Deepest nesting of `(`, `[` and `{`.
|
||||
const MAX_BRACKET_DEPTH: usize = 64;
|
||||
|
||||
/// Longest run of prefix operators (`!`, `~`, `+`, `-`).
|
||||
const MAX_PREFIX_RUN: usize = 64;
|
||||
|
||||
/// Most `?` (conditional expressions nest without brackets).
|
||||
const MAX_CONDITIONALS: usize = 4096;
|
||||
|
||||
/// The script thread's stack.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) const SCRIPT_STACK: usize = 1 << 30;
|
||||
|
||||
/// Why `source` is refused, if it is: too long, or nested deeper than any form needs. Strings
|
||||
/// and comments are skipped; brackets in regular expressions and templates count, which only
|
||||
/// errs on the safe side.
|
||||
/// Why `source` is refused, if it is: too long, or nested deeper than any form needs, which
|
||||
/// would overflow the stack in boa's parser ([`crate::nesting`]).
|
||||
pub(crate) fn refuse(source: &str) -> Option<String> {
|
||||
if source.len() > MAX_SCRIPT_BYTES {
|
||||
return Some(format!("the script is too long ({} KiB; the limit is {} KiB)", source.len() / 1024, MAX_SCRIPT_BYTES / 1024));
|
||||
}
|
||||
let too_deep = || Some("the script is nested too deeply to run safely".to_string());
|
||||
let b = source.as_bytes();
|
||||
let (mut depth, mut prefix, mut conditionals) = (0usize, 0usize, 0usize);
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
i += 1;
|
||||
match c {
|
||||
b'\'' | b'"' => {
|
||||
// To the closing quote (or the end of the line, where an unclosed string ends).
|
||||
while let Some(&d) = b.get(i) {
|
||||
i += 1;
|
||||
if d == b'\\' {
|
||||
i += 1;
|
||||
} else if d == c || d == b'\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
prefix = 0;
|
||||
}
|
||||
b'/' if b.get(i) == Some(&b'/') => {
|
||||
while b.get(i).is_some_and(|d| *d != b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'/' if b.get(i) == Some(&b'*') => {
|
||||
i += 1;
|
||||
while i < b.len() && !b[i..].starts_with(b"*/") {
|
||||
i += 1;
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
b'(' | b'[' | b'{' => {
|
||||
depth += 1;
|
||||
if depth > MAX_BRACKET_DEPTH {
|
||||
return too_deep();
|
||||
}
|
||||
prefix = 0;
|
||||
}
|
||||
b')' | b']' | b'}' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
prefix = 0;
|
||||
}
|
||||
b'!' | b'~' | b'+' | b'-' => {
|
||||
prefix += 1;
|
||||
if prefix > MAX_PREFIX_RUN {
|
||||
return too_deep();
|
||||
}
|
||||
}
|
||||
b'?' => {
|
||||
conditionals += 1;
|
||||
if conditionals > MAX_CONDITIONALS {
|
||||
return too_deep();
|
||||
}
|
||||
prefix = 0;
|
||||
}
|
||||
c if c.is_ascii_whitespace() => {}
|
||||
_ => prefix = 0,
|
||||
}
|
||||
}
|
||||
None
|
||||
crate::nesting::too_deep(source).then(|| "the script is nested too deeply to run safely".to_string())
|
||||
}
|
||||
|
||||
/// Run `script` for `event`, after the document-level scripts (`doc_scripts`, which define the
|
||||
|
||||
+173
-2
@@ -666,6 +666,8 @@ fn deeply_nested_scripts_fail_instead_of_crashing() {
|
||||
assert!(o.doc_errors.len() == 1 && o.doc_errors[0].contains("nested too deeply"), "{:?}", o.doc_errors);
|
||||
}
|
||||
|
||||
/// Ordinary nesting below the guard's limits runs, and brackets in strings and comments don't
|
||||
/// count, however many there are.
|
||||
#[test]
|
||||
fn ordinary_nesting_still_runs() {
|
||||
let n = 40;
|
||||
@@ -673,11 +675,164 @@ fn ordinary_nesting_still_runs() {
|
||||
let o = go(&s, &Event::field("Calculate", "a", ""));
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "5");
|
||||
// Brackets in strings and comments don't count.
|
||||
let s = format!("// {}\nevent.value = '{}'.length;", "(".repeat(1000), "[".repeat(1000));
|
||||
let s = format!("// {}\n/* {} */ event.value = '{}'.length;", "(".repeat(1000), "{".repeat(1000), "[".repeat(1000));
|
||||
assert_eq!(go(&s, &Event::field("Calculate", "a", "")).value, "1000");
|
||||
}
|
||||
|
||||
/// The reported #5079 reproduction: a regex literal whose body holds a quote (`/'/`) must not
|
||||
/// hide the brackets that follow it from the nesting guard. The old scan read `'` as the start
|
||||
/// of a string, so every `(` after `/'/` on the line was skipped; boa's unbounded recursive
|
||||
/// parser then overflowed the script thread's stack (the console's `js_run` path, on the
|
||||
/// `pdfcraft-js` thread) and aborted the app. With that scan, 200 brackets fail this assertion
|
||||
/// cleanly (boa reports a SyntaxError) and 64,000, the issue's file, overflow the stack.
|
||||
#[test]
|
||||
fn a_quote_in_a_regex_cannot_hide_nesting_from_the_guard() {
|
||||
for n in [200, 64_000] {
|
||||
let s = format!("event.value = /'/ {}1;", "(".repeat(n));
|
||||
let o = go(&s, &Event::field("Calculate", "a", ""));
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{n}: {:?}", o.error);
|
||||
}
|
||||
}
|
||||
|
||||
/// A quote in a regex character class, with the real nesting after it, is refused, while
|
||||
/// ordinary string and regex literals still run, strings of brackets of any length among them.
|
||||
#[test]
|
||||
fn nesting_guard_counts_brackets_hidden_by_a_regex_quote() {
|
||||
let hidden = format!("var r = /['\"]/; event.value = {}1{}; event.value = 'ok';", "(".repeat(65), ")".repeat(65));
|
||||
assert!(super::run::refuse(&hidden).is_some_and(|e| e.contains("nested too deeply")), "the regex quote must not hide parser nesting");
|
||||
|
||||
let o = go("event.value = '()[]{}'.length;", &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "6"));
|
||||
let o = go("var r = /['\"]/; event.value = r.test(\"'\") ? 1 : 0;", &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "1"));
|
||||
for n in [64, 65, 10_000] {
|
||||
let o = go(&format!("event.value = '{}'.length;", "(".repeat(n)), &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value), (None, n.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
/// A script that nests one level deeper on each line, behind a quote inside a regular
|
||||
/// expression: `x=` followed by lines of `(/"/,"))))",` (#5079, after the first fix). A scan
|
||||
/// that reads the regex's quote as the start of a string is out of phase with the real strings
|
||||
/// from there on: it reads `"/,"` as a string and the closing brackets in the real string
|
||||
/// `"))))"` as code, so on every line its depth falls back to 0 while boa's grows by one. 5,000
|
||||
/// lines are 65 KB, under the size limit, and nest 5,000 deep. The same with `'`, and with
|
||||
/// template text, which that scan didn't skip at all, is refused too. As deep as the limit,
|
||||
/// closed properly, is ordinary code and runs.
|
||||
#[test]
|
||||
fn a_quote_in_a_regex_cannot_put_the_guard_out_of_phase() {
|
||||
use super::nesting::MAX_BRACKET_DEPTH;
|
||||
for q in ["\"", "'", "`"] {
|
||||
let line = format!("(/{q}/,{q}))))){q},\n");
|
||||
for n in [MAX_BRACKET_DEPTH + 1, 5_000] {
|
||||
let s = format!("x={}", line.repeat(n));
|
||||
assert!(s.len() <= super::MAX_SCRIPT_BYTES);
|
||||
assert!(super::run::refuse(&s).is_some_and(|e| e.contains("nested too deeply")), "{q} x {n}");
|
||||
}
|
||||
// The real strings alone, with no regex in front.
|
||||
let s = format!("x={}", format!("({q}))))){q},").repeat(MAX_BRACKET_DEPTH + 1));
|
||||
assert!(super::run::refuse(&s).is_some_and(|e| e.contains("nested too deeply")), "{q} without the regex");
|
||||
|
||||
let n = MAX_BRACKET_DEPTH;
|
||||
let s = format!("var x={}0{};\nevent.value = x;", line.repeat(n), ")".repeat(n));
|
||||
let o = go(&s, &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "0"), "{q}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Template substitutions nest like brackets, and template text, comments and regular
|
||||
/// expressions full of brackets, quotes and slashes still run.
|
||||
#[test]
|
||||
fn nesting_guard_reads_templates_regexes_and_comments() {
|
||||
use super::nesting::MAX_BRACKET_DEPTH;
|
||||
let nested = |n: usize| (0..n).fold("1".to_string(), |s, _| format!("`${{{s}}}`"));
|
||||
let o = go(&format!("event.value = {};", nested(10)), &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "1"));
|
||||
assert!(super::run::refuse(&nested(MAX_BRACKET_DEPTH + 1)).is_some_and(|e| e.contains("nested too deeply")));
|
||||
|
||||
let s = [
|
||||
"var r = /[(\\[{'\"`/]+/g, s = \"a(b[c{d'e`f/g\";",
|
||||
"var t = `((( [[[ {{{ ' \" / ${s.replace(r, '')} ${'}'} )`;",
|
||||
"var u = /\\(/.source + /[)]/.source + /(a)(?:b)/.source + /a{2}/.source;",
|
||||
"// a comment with ((( and ' and \"",
|
||||
"/* another with [[[ and ` and / */",
|
||||
"var v = 10 / 2 / 5, w = (10 / 2) / 5, z = 1; z--> 0; <!-- an old-style comment (((",
|
||||
"event.value = [t.length, u, v, w].join(' ');",
|
||||
]
|
||||
.join("\n");
|
||||
let o = go(&s, &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "29 \\([)](a)(?:b)a{2} 1 1"));
|
||||
}
|
||||
|
||||
/// A leading `#!` line is a comment: brackets in it don't count, while nesting on the lines
|
||||
/// after it still does.
|
||||
#[test]
|
||||
fn a_hashbang_line_is_read_as_a_comment() {
|
||||
use super::nesting::MAX_BRACKET_DEPTH;
|
||||
let s = format!("#!{}\nevent.value = 1;", "(".repeat(MAX_BRACKET_DEPTH + 1));
|
||||
let o = go(&s, &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "1"));
|
||||
let n = MAX_BRACKET_DEPTH + 1;
|
||||
let s = format!("#!\nevent.value = {}1{};", "(".repeat(n), ")".repeat(n));
|
||||
assert!(super::run::refuse(&s).is_some_and(|e| e.contains("nested too deeply")));
|
||||
}
|
||||
|
||||
/// Divisions don't add up to a refusal: a form's calculation rounds with `/` many times over,
|
||||
/// and on one long line, and each `/` could start a regular expression as far as the scan
|
||||
/// knows. The reading as a regular expression is dropped where boa would reject it.
|
||||
#[test]
|
||||
fn divisions_do_not_add_up_to_a_refusal() {
|
||||
let s = format!("var a = 7, b = 2, x = 0;\n{}event.value = x;", "x = Math.round(a / b * 100) / 100; x = (a / b) / (b / b);\n".repeat(500));
|
||||
assert!(s.len() <= super::MAX_SCRIPT_BYTES);
|
||||
let o = go(&s, &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "3.5"));
|
||||
let s = format!("event.value = 1{};", "/1".repeat(2_000));
|
||||
let o = go(&s, &Event::field("Calculate", "a", ""));
|
||||
assert_eq!((o.error, o.value.as_str()), (None, "1"));
|
||||
}
|
||||
|
||||
/// What the guard's reading assumes boa does: a string or regular expression can't run into a
|
||||
/// line break, and a regular expression's groups must match, all as syntax errors when boa
|
||||
/// reads the script. The guard drops a reading at those points; if boa ever accepted one of
|
||||
/// these, that reading could be boa's, and the guard would have to keep it.
|
||||
#[test]
|
||||
fn boa_rejects_what_the_nesting_guard_drops() {
|
||||
for s in ["var r = /)/;", "var r = /(/;", "var r = /a)(b/;", "var r = /a\n/;", "var r = /a\\\n/;", "var s = 'a\nb';", "var s = \"a\rb\";"] {
|
||||
// Rejected while boa reads the script, before the line in front of it runs.
|
||||
let o = go(&format!("console.println('ran');\n{s}"), &Event::field("Calculate", "a", ""));
|
||||
assert!(o.error.as_deref().is_some_and(|e| !e.contains("nested too deeply")), "{s:?}: {:?}", o.error);
|
||||
assert!(o.console.is_empty(), "{s:?} was rejected only when it ran: {:?}", o.console);
|
||||
}
|
||||
for s in ["var r = /[(]/;", "var r = /\\)/;", "var r = /[)]/;", "var s = 'a\\\nb';", "var s = 'a\u{2028}b';"] {
|
||||
let o = go(s, &Event::field("Calculate", "a", ""));
|
||||
assert_eq!(o.error, None, "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The guard's three limits sit exactly where documented: 64 nested brackets, a run of 64
|
||||
/// prefix operators and 4,096 conditionals pass, and one more of each is refused.
|
||||
#[test]
|
||||
fn nesting_guard_limits_brackets_prefixes_and_conditionals_separately() {
|
||||
assert_eq!(super::run::refuse(&format!("{}1{}", "(".repeat(64), ")".repeat(64))), None);
|
||||
assert!(super::run::refuse(&format!("{}1{}", "(".repeat(65), ")".repeat(65))).is_some());
|
||||
|
||||
assert_eq!(super::run::refuse(&format!("{}1", "!".repeat(64))), None);
|
||||
assert!(super::run::refuse(&format!("{}1", "!".repeat(65))).is_some());
|
||||
|
||||
assert_eq!(super::run::refuse(&"?".repeat(4096)), None);
|
||||
assert!(super::run::refuse(&"?".repeat(4097)).is_some());
|
||||
}
|
||||
|
||||
/// Closing brackets inside a comment don't cancel real nesting (#5079): 1,000 groups of 50
|
||||
/// opening brackets, each followed by a block comment holding 50 closing ones, nest 50,000 deep
|
||||
/// for the parser, and the guard refuses the script before boa parses it.
|
||||
#[test]
|
||||
fn closing_brackets_in_comments_do_not_cancel_nesting() {
|
||||
let group = format!("{}/*{}*/", "(".repeat(50), ")".repeat(50));
|
||||
let s = group.repeat(1000);
|
||||
assert_eq!(super::run::refuse(&s).as_deref(), Some("the script is nested too deeply to run safely"));
|
||||
}
|
||||
|
||||
/// 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]
|
||||
@@ -1989,6 +2144,22 @@ mod session {
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// The #5079 reproduction on the XFA-open path: an initialize script of `/'/` followed by
|
||||
/// 64,000 `(` overflowed the `pdfcraft-xfa-session` thread's stack and aborted the app. The
|
||||
/// nesting guard reads past the regex's quote, so the script is refused and the session stays
|
||||
/// alive for the next one. So is one where the quote puts later strings out of phase, nesting
|
||||
/// a level deeper on each of 5,000 lines.
|
||||
#[test]
|
||||
fn a_quote_in_a_regex_cannot_hide_nesting_from_an_initialize_script() {
|
||||
let mut s = XfaSession::new(XfaDoc::default());
|
||||
for hostile in [format!("/'/ {}1", "(".repeat(64_000)), format!("x={}", "(/\"/,\"))))\",\n".repeat(5_000))] {
|
||||
let o = s.run(script(&hostile, false, "form1[0].page1[0].total[0]", "initialize"));
|
||||
assert!(o.error.as_deref().is_some_and(|e| e.contains("nested too deeply")), "{:?}", o.error);
|
||||
}
|
||||
assert_eq!(s.dead(), None);
|
||||
assert_eq!(s.run(script("1 + 1", false, "form1[0].page1[0].total[0]", "calculate")).result.as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
/// A JavaScript or FormCalc script whose engine takes longer to be ready than the script's
|
||||
/// time limit still runs and returns its result, and the session stays alive.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user