Security: EPS and legacy Illustrator bounds (operator recursion, bind, copy, cvn, previews, writer output, ink name) (#3883)

* Audit: EPS interpreter and import bounds

* Audit: EPS writer stops expanding at 256 MB of body

* fix(eps): enforce MAX_BODY budget before committing node and image output

* chore(text): use is_some_and instead of unnecessary map_or (clippy)

* Fix clippy unnecessary_map_or false-default in ITXT compression flag scan

* Reject EPSI previews with an odd number of hex digits

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dylan Taylor <dylan@craftmaker.dev>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Dylan M. Taylor
2026-10-25 12:32:14 -04:00
committed by GitHub
co-authored by Claude Sonnet 5.5 Dylan Taylor
parent f792c63597
commit 6b0df68cc6
5 changed files with 50 additions and 7 deletions
+1
View File
@@ -1077,6 +1077,7 @@ impl<'a> Reader<'a> {
/// `kids` with the clipping paths among them (indexes `clips`) first, several as one compound
/// path → the children and whether they clip.
fn clip_first(&mut self, kids: Vec<Node>, clips: &[usize]) -> (Vec<Arc<Node>>, bool) {
let clips: std::collections::HashSet<usize> = clips.iter().copied().collect();
let (mut clip, rest): (Vec<Indexed>, Vec<Indexed>) = kids.into_iter().enumerate().partition(|(i, _)| clips.contains(i));
let clip_node = match clip.len() {
0 => None,
+21 -6
View File
@@ -381,7 +381,13 @@ impl<'a> Interp<'a> {
None => Ok(()),
},
Obj::ExecStr(s) => self.run_program(&s.to_vec()),
Obj::Op(op) => self.op(op),
// Through the depth guard: `stopped` and the like call back into here.
Obj::Op(op) => {
self.enter()?;
let r = self.op(op);
self.depth -= 1;
r
}
Obj::Exec(name) => {
self.enter()?;
let r = match self.lookup(&name) {
@@ -819,6 +825,8 @@ impl<'a> Interp<'a> {
}
Cvn => {
let s = self.pop_str()?;
// The name is a copy of the string (a bigger one when bytes are replaced).
self.alloc(s.len().saturating_mul(3))?;
let n: Rc<str> = Rc::from(String::from_utf8_lossy(&s.borrow()).as_ref());
self.push(Obj::Name(n))?;
}
@@ -1055,7 +1063,7 @@ impl<'a> Interp<'a> {
Bind => {
let o = self.pop()?;
if let Obj::Array { items, .. } = &o {
self.bind(items, 0);
self.bind(items, 0)?;
}
self.push(o)?;
}
@@ -1171,7 +1179,11 @@ impl<'a> Interp<'a> {
}
}
Obj::Array { items, exec } => {
let from = self.pop_array()?.to_vec();
let source = self.pop_array()?;
// Its elements are read and written: work in proportion, and a copy held meanwhile.
self.spend(source.len() as u64)?;
self.alloc(source.len().saturating_mul(std::mem::size_of::<Obj>()))?;
let from = source.to_vec();
if !items.write(0, &from) {
return ps_err("rangecheck", "copy");
}
@@ -1246,11 +1258,13 @@ impl<'a> Interp<'a> {
/// Replace the names in a procedure that stand for operators by the operators, down through
/// nested procedures.
fn bind(&mut self, items: &Shared<Obj>, depth: u32) {
fn bind(&mut self, items: &Shared<Obj>, depth: u32) -> Res {
if depth > 64 {
return;
return Ok(());
}
for i in 0..items.len() {
// Procedures that hold one another twice would be visited exponentially often.
self.tick()?;
match items.get(i) {
Some(Obj::Exec(name)) => {
if let Some(o @ Obj::Op(_)) = self.lookup(&name) {
@@ -1258,10 +1272,11 @@ impl<'a> Interp<'a> {
items.write(i, &[o]);
}
}
Some(Obj::Array { items: inner, exec: true }) if !inner.shares(items) => self.bind(&inner, depth + 1),
Some(Obj::Array { items: inner, exec: true }) if !inner.shares(items) => self.bind(&inner, depth + 1)?,
_ => {}
}
}
Ok(())
}
/// Put `v` under `k` in `d` (`None`: remove it), within [`MAX_ALLOC`] entries; while a `save`
+9
View File
@@ -99,7 +99,16 @@ fn epsi(ps: &[u8]) -> Option<image::RgbaImage> {
}
hex.extend(l.strip_prefix('%')?.bytes().filter(u8::is_ascii_hexdigit));
}
hex.truncate(row * h * 2);
// A trailing half byte would decode padded with zero: the preview is cut short, so none.
if hex.len() % 2 != 0 {
return None;
}
let bytes = super::lex::hex_decode(&hex)?;
// A preview with its samples missing is none, not a blank image of the size it claims.
if bytes.len() < row * h {
return None;
}
let max = (1u32 << depth) - 1;
let mut img = image::RgbaImage::new(w as u32, h as u32);
for (x, y, px) in img.enumerate_pixels_mut() {
+2 -1
View File
@@ -75,7 +75,8 @@ fn sheet(p: &PrintPage, job: &PrintJob) -> Result<(String, Vec<Page>), String> {
let (wi, hi) = (w.ceil() as i64, h.ceil() as i64);
let _ = writeln!(s, "%%PageBoundingBox: 0 0 {wi} {hi}");
if let Some((name, _, _)) = p.ink {
let _ = writeln!(s, "%%PlateColor: {name}");
// A line break would end the comment and the rest would run as PostScript.
let _ = writeln!(s, "%%PlateColor: {}", name.replace(['\r', '\n'], " "));
}
s.push_str("%%BeginPageSetup\n");
let _ = writeln!(s, "/setpagedevice where {{pop << /PageSize [{} {}] >> setpagedevice}} if", ps::num(w), ps::num(h));
+17
View File
@@ -39,6 +39,10 @@ const UNDECODED: &str = "an image couldn't be decoded and is missing from the pi
const LARGE_IMAGE: &str = "an image that is too large for EPS was left out";
const SPOT_GRADIENTS: &str = "gradients of spot colours are written in process colours";
const MISSING_PATTERN: &str = "missing patterns are written as mid-grey";
const TOO_LARGE: &str = "the artwork is too large for EPS: what follows 256 MB of PostScript was left out";
/// The most PostScript the page body may grow to: symbols and patterns that hold one another
/// expand into every instance, so nesting depth alone doesn't bound the output.
const MAX_BODY: usize = 256 << 20;
/// What the walk wrote: definitions for the setup, the page, the spot colours it uses (name and
/// CMYK equivalent) and the warnings.
@@ -264,12 +268,17 @@ impl<'a> Scene<'a> {
if let NodeKind::Layer { template: true, .. } = n.kind {
return;
}
if self.body.len() >= MAX_BODY {
self.warn(TOO_LARGE);
return;
}
match n.visual_bounds_with(&self.symbols) {
Some(b) if !force && !overlaps(b.inflate(1.0, 1.0), self.o.region) => return,
None if !n.is_container() => return,
_ => {}
}
self.note_losses(n);
let was = self.body.len();
match &n.kind {
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().for_each(|c| self.node(c, false));
@@ -313,6 +322,10 @@ impl<'a> Scene<'a> {
}
}
}
if self.body.len() > MAX_BODY {
self.body.truncate(was);
self.warn(TOO_LARGE);
}
}
/// A clipping group: the art inside its first child's outline. The clipping path's fills
@@ -891,6 +904,10 @@ impl<'a> Scene<'a> {
if !invertible(place) {
return;
}
if self.body.len().saturating_add(encoded.len()).saturating_add(64) > MAX_BODY {
self.warn(TOO_LARGE);
return;
}
self.line("q");
let _ = writeln!(self.body, "{} cm", ps::matrix(place));
self.body.push_str(encoded);