security(affinity): charge shared-object expansion against document-wide budgets (#8938)

Reject zero-width fixed-record arrays, borrow pixel state arrays instead of
collecting them per occurrence, charge FiEf slots, curve records and layer
name bytes across the document, bound mixed-text expansion before appending,
and cap distinct allocation-table ids (deletion records now hold no Entry).
This commit is contained in:
Dylan M. Taylor
2026-10-23 14:30:21 -04:00
committed by GitHub
parent cf0960b520
commit b11ecc6ebf
7 changed files with 92 additions and 19 deletions
+9 -3
View File
@@ -15,6 +15,8 @@ const DOCUMENT_KIND: &[u8; 4] = b"nsrP";
/// A save appends one table; real files have one to a few hundred.
const MAX_TABLES: usize = 4096;
/// Distinct ids the allocation tables may name; each costs a map slot even when only deleted.
const MAX_ENTRIES: usize = 1 << 22;
/// zstd window ceiling: real documents use a few MiB; this stops a header from reserving gigabytes.
const MAX_ZSTD_WINDOW: u64 = 64 << 20;
@@ -164,7 +166,7 @@ impl<'a> Archive<'a> {
pub fn open(bytes: &'a [u8], limits: Limits) -> Result<Self, Error> {
let header = header(bytes)?;
// Newest record per id: (table date, table index, record).
let mut latest: HashMap<u32, (u64, usize, Option<Entry>)> = HashMap::new();
let mut latest: HashMap<u32, (u64, usize, Option<Box<Entry>>)> = HashMap::new();
let mut names: HashMap<u32, String> = HashMap::new();
let mut seen = HashSet::new();
let mut next = header.fat_offset;
@@ -222,7 +224,7 @@ impl<'a> Archive<'a> {
} else {
stored
};
record = Some(Entry { id, name: String::new(), offset, size, compressed, crc, compression });
record = Some(Box::new(Entry { id, name: String::new(), offset, size, compressed, crc, compression }));
}
2 => {}
_ => return Err(Error::Malformed("unknown allocation record")),
@@ -235,6 +237,10 @@ impl<'a> Archive<'a> {
// Equal dates: the first table in the chain (the newest) wins, as in afread.
let replace = latest.get(&id).is_none_or(|(d, _, _)| date > *d);
if replace {
// Deletion records are five bytes each: cap what distinct ids may retain.
if latest.len() >= MAX_ENTRIES && !latest.contains_key(&id) {
return Err(Error::Limit("too many archive entries"));
}
latest.insert(id, (date, tables, record));
}
}
@@ -248,7 +254,7 @@ impl<'a> Archive<'a> {
for (id, (_, _, record)) in latest {
if let (Some(mut e), Some(name)) = (record, names.get(&id)) {
e.name = name.clone();
entries.insert(e.name.clone(), e);
entries.insert(e.name.clone(), *e);
}
}
Ok(Self { bytes, header, entries, revisions: tables, extracted: 0, limits })
+27 -1
View File
@@ -9,17 +9,21 @@ const MAX_NODES: usize = 4_000_000;
const MAX_COMPOUND_DEPTH: usize = 64;
const MAX_OUTLINE_VISITS: usize = 500_000;
const MAX_OUTLINE_POINTS: usize = 4_000_000;
/// Curve records decoded across the document. A shared curve is decoded again for each
/// occurrence, and records that only move handles add no points.
const MAX_OUTLINE_RECORDS: usize = 8_000_000;
/// Geometry is expanded per occurrence, since parametric outlines can depend on their world
/// transform. Bound work across the entire document instead of memoizing by object id alone.
pub(crate) struct Budget {
visits_left: usize,
points_left: usize,
records_left: usize,
}
impl Default for Budget {
fn default() -> Self {
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS }
Self { visits_left: MAX_OUTLINE_VISITS, points_left: MAX_OUTLINE_POINTS, records_left: MAX_OUTLINE_RECORDS }
}
}
@@ -30,6 +34,12 @@ impl Budget {
Ok(())
}
/// Charge curve records before they are decoded.
fn records(&mut self, n: usize) -> Result<(), &'static str> {
self.records_left = self.records_left.checked_sub(n).ok_or("curve geometry budget exhausted")?;
Ok(())
}
/// Charge both leaf geometry and each copy into a compound before allocating that copy.
fn path(&mut self, path: &Path) -> Result<(), &'static str> {
let points = path.subpaths.iter().fold(0usize, |n, s| n.saturating_add(1).saturating_add(s.segments.len().saturating_mul(3)));
@@ -152,6 +162,10 @@ pub(crate) fn curves(r: &mut Reader, crvs: ObjId) -> Option<Path> {
r.warn("curves with more than four million nodes");
return None;
}
if let Err(reason) = r.geometry_budget.records(records.len()) {
r.warn(reason);
return None;
}
let anchors = anchors(records)?;
let radii = if index == 0 { corners.as_ref() } else { None };
if let Some(sp) = subpath(&anchors, closed, radii) {
@@ -258,3 +272,15 @@ fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn curve_records_are_charged_across_the_document() {
let mut b = Budget::default();
assert!(b.records(MAX_OUTLINE_RECORDS).is_ok());
assert_eq!(b.records(1), Err("curve geometry budget exhausted"));
}
}
+22 -5
View File
@@ -288,6 +288,8 @@ const MAX_NODES: usize = 500_000;
/// nodes, so a shared group holding a large null-filled child array could still be rescanned
/// many times; this bounds that work. Generous relative to MAX_NODES for real documents.
const MAX_CHILD_SLOTS: usize = 8_000_000;
/// Bytes of layer names the model may retain across all occurrences.
const MAX_NAME_BYTES: usize = 64 << 20;
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
@@ -304,6 +306,7 @@ pub(crate) struct Reader<'s, 'a, 'b> {
warnings: BTreeMap<String, usize>,
nodes: usize,
child_slots: usize,
name_bytes: usize,
active: HashSet<ObjId>,
pub(crate) geometry_budget: crate::geometry::Budget,
/// Decoded pixels still available ([`Limits::max_pixel_bytes`] less what the layers read so
@@ -320,6 +323,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
warnings: BTreeMap::new(),
nodes: 0,
child_slots: 0,
name_bytes: 0,
active: HashSet::new(),
geometry_budget: crate::geometry::Budget::default(),
pixels_left: limits.max_pixel_bytes,
@@ -394,10 +398,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
// Charge every raw child slot (null slots included) against a document-wide budget
// before filtering. A shared group with a large null-filled Chld array could otherwise
// be rescanned through each reference without the node limit noticing.
self.child_slots = self.child_slots.saturating_add(s.array_len(parent, b"Chld").unwrap_or(0));
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
self.charge_slots(s.array_len(parent, b"Chld").unwrap_or(0))?;
for c in s.objs(parent, b"Chld") {
if s.is(c, b"MRst") {
// A mask layer inside a group or layer masks that container.
@@ -419,6 +420,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Ok((out, mask))
}
/// Counts raw array slots (null ones included) against [`MAX_CHILD_SLOTS`].
fn charge_slots(&mut self, n: usize) -> Result<(), Error> {
self.child_slots = self.child_slots.saturating_add(n);
if self.child_slots > MAX_CHILD_SLOTS {
return Err(Error::Limit("too many child slots in the layer tree"));
}
Ok(())
}
fn node(&mut self, id: ObjId, parent: Affine, depth: usize) -> Result<Option<Node>, Error> {
let s = self.s;
if depth > MAX_TREE_DEPTH {
@@ -437,7 +447,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
// A shared layer's name is copied for every occurrence, so charge the bytes first.
let name = s.str(id, b"Desc").unwrap_or_default();
self.name_bytes = self.name_bytes.saturating_add(name.len());
if self.name_bytes > MAX_NAME_BYTES {
return Err(Error::Limit("layer names too large"));
}
let name = name.to_string();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
@@ -460,6 +476,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let (mask, attached_mask) = self.attached_masks(id, world)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
self.charge_slots(s.array_len(id, b"FiEf").unwrap_or(0))?;
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
self.warn("layer effects (shadows, glows, outlines, bevels…)");
}
+13 -7
View File
@@ -181,24 +181,30 @@ fn plane(
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
// Borrow the file-controlled arrays: collecting the whole state array first costs its full
// length on every occurrence of a shared layer, however small the declared grid is.
let states: &[Value] = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a,
_ => &[],
};
let tiles = s.objs(bitmap, &tag(b"Idx", c));
let mut tiles = match s.field(bitmap, &tag(b"Idx", c)) {
Some(Value::Array(a)) => a.as_slice(),
_ => &[],
}
.iter()
.filter_map(|v| if let Value::Obj(Some(o)) = v { Some(*o) } else { None });
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
let state = &if let Value::UInt(u) = state { u8::try_from(*u).unwrap_or(255) } else { 255 };
let (ty, tx) = (t / tw.max(1), t % tw.max(1));
let fill = match state {
0 | 1 => None,
2 => Some(0xFF),
4 => {
let Some(blck) = tiles.get(next).copied() else { return Err(Error::Malformed("missing pixel tile")) };
next += 1;
let Some(blck) = tiles.next() else { return Err(Error::Malformed("missing pixel tile")) };
let (ox, oy) = (tx * TILE, ty * TILE);
if ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0 {
continue;
+2 -1
View File
@@ -332,7 +332,8 @@ impl Parser<'_> {
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
// A zero width passes the length check for any count, then costs a Value per record.
if size == 0 || n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
let mut v = self.vec(n)?;
+11
View File
@@ -302,6 +302,17 @@ mod archive {
}
}
#[test]
fn fixed_records_of_zero_width_are_rejected_before_any_value_is_made() {
// Four million records of no bytes each pass a length check that multiplies by the width.
let mut f = vec![0x80 | 0x2c];
f.extend(tag(b"Big_").0.to_le_bytes());
f.extend(4_000_000u32.to_le_bytes());
f.extend(0u16.to_le_bytes());
let d = synth::stream(&[(tag(b"Big_"), F::Raw(f))]);
assert_eq!(stream::parse(&d).unwrap_err(), Error::Malformed("array longer than its data"));
}
#[test]
fn a_small_compressed_entry_cannot_make_the_stream_allocate_gigabytes() {
// Each decoded value takes about 40 bytes, and a bool array packs eight in a byte: 16 MiB
+8 -2
View File
@@ -26,11 +26,17 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let mut v = Vec::new();
// One past the allowance is enough to trip the check below; shared segments
// repeated by reference must not expand beyond it first.
let room = MAX_CHARS.saturating_sub(total).saturating_add(1);
for seg in s.objs(glyphs, b"Mixd") {
if v.len() >= room {
break;
}
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline));
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(room - v.len())));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars());
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(room.saturating_sub(v.len())));
}
v
}