mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
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:
@@ -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 })
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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…)");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user