diff --git a/crates/render/src/dim.rs b/crates/render/src/dim.rs index f38e7ea0..e79b7b4e 100644 --- a/crates/render/src/dim.rs +++ b/crates/render/src/dim.rs @@ -414,7 +414,14 @@ fn tol_height_code(st: &DimStyle) -> String { } fn post(template: &str, meas: &str) -> String { - if template.contains("<>") { template.replace("<>", meas) } else { format!("{meas}{template}") } + if template.contains("<>") { + bounded_replace(template, meas) + } else { + let mut out = String::new(); + append_dim_text(&mut out, meas); + append_dim_text(&mut out, template); + out + } } fn apply_override(base: &str, d: &Dimension) -> String { @@ -423,10 +430,40 @@ fn apply_override(base: &str, d: &Dimension) -> String { } else if d.text.trim().is_empty() { String::new() } else { - d.text.replace("<>", base) + bounded_replace(&d.text, base) } } +/// Largest dimension text produced by substituting the measurement into style/override text. Two +/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers +/// (CWE-789); past this the output is truncated rather than allocated unbounded. +const MAX_DIM_TEXT: usize = 64 * 1024; + +/// Replace every `<>` in `template` with `value`, with the expanded length checked against +/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory. +fn bounded_replace(template: &str, value: &str) -> String { + let mut out = String::new(); + let mut rest = template; + while let Some(i) = rest.find("<>") { + if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) { + return out; + } + rest = &rest[i + 2..]; + } + append_dim_text(&mut out, rest); + out +} + +// Returns false when truncation consumed the remaining UTF-8-safe space. +fn append_dim_text(out: &mut String, text: &str) -> bool { + let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len())); + while !text.is_char_boundary(end) { + end -= 1; + } + out.push_str(&text[..end]); + end == text.len() +} + fn ext_line(origin: Vec2, foot: Vec2, st: &DimStyle, k: f64) -> Option> { let v = foot - origin; let len = v.len(); @@ -1322,3 +1359,21 @@ mod tests { assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines); } } + +#[cfg(test)] +mod text_budget_tests { + use super::*; + #[test] + fn bounds_post_without_markers() { + let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12"); + assert_eq!(text.len(), MAX_DIM_TEXT); + assert!(text.starts_with("12x")); + } + #[test] + fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() { + let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT)); + assert!(text.starts_with("prefix é")); + assert!(!text.contains("<>")); + assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1); + } +} diff --git a/crates/render/src/hatch.rs b/crates/render/src/hatch.rs index e8d75375..b297618a 100644 --- a/crates/render/src/hatch.rs +++ b/crates/render/src/hatch.rs @@ -80,18 +80,47 @@ fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool { } fn dedup_polygons(loops: &mut Vec>) { + // `same_polygon` is O(n²) in a polygon's vertex count (every cyclic alignment, both windings), + // and each loop is compared against every unique one kept so far, so adversarial boundary + // geometry could force quadratic-on-quadratic work. Charge every vertex comparison against a + // shared budget; once it is spent, stop deduplicating and keep the remaining loops as-is + // (CWE-407). + let mut budget: u64 = MAX_DEDUP_COMPARISONS; let mut unique: Vec> = Vec::with_capacity(loops.len()); for l in loops.drain(..) { if l.len() < 3 { continue; } - if !unique.iter().any(|u| same_polygon(u, &l)) { + let mut dup = false; + for u in &unique { + if u.len() != l.len() { + continue; + } + // Each `same_polygon` call costs about 2·len² comparisons; stop if that would overrun. + let cost = 2u64.saturating_mul((u.len() as u64).saturating_mul(l.len() as u64)); + match budget.checked_sub(cost) { + Some(left) => budget = left, + None => { + // Out of budget: keep `l` without further comparison. + budget = 0; + break; + } + } + if same_polygon(u, &l) { + dup = true; + break; + } + } + if !dup { unique.push(l); } } *loops = unique; } +/// Vertex comparisons `dedup_polygons` may perform before it stops deduplicating. +const MAX_DEDUP_COMPARISONS: u64 = 50_000_000; + /// The tessellated boundary loops that take part in the fill under the hatch's island /// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands /// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2 @@ -261,6 +290,9 @@ const MAX_PATTERN_LINES: usize = 1024; /// (a hostile or mis-scaled pattern can't exhaust memory). A view-limited build makes at most the /// runs of a full build, so it never leaves out runs that a full build draws. const MAX_RUNS: usize = 1_000_000; +/// Shared budget on dash-processing steps across all of a hatch's families and spans, counted +/// independently of emitted geometry so gap-only patterns cannot spin without bound (CWE-400). +const MAX_HATCH_WORK: u64 = 50_000_000; /// Upper bound on the lines one family sweeps over the whole hatch; past it the family is left /// out (a full-detail build of a pattern far too fine for its area). const MAX_FAMILY_LINES: i64 = 20_000; @@ -351,7 +383,16 @@ pub(crate) fn pattern_lines_until( let scale = scale_of(h); let px = if px.is_finite() && px > 0.0 { px } else { 0.0 }; let mut out = Vec::new(); + // Shared work budget across all families and spans, counting dash-processing steps even when + // they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound + // their cost (CWE-400). + let mut work: u64 = MAX_HATCH_WORK; for fam in &lines { + // A family with dashes but no positive (ink) element can never draw: skip it so its gaps + // are not swept at all. + if !fam.dashes.is_empty() && !fam.dashes.iter().any(|d| *d >= 0.0) { + continue; + } let ang = fam.angle.to_radians() + h.angle; let origin = h.origin + Vec2::new(fam.origin.0, fam.origin.1).rotate(h.angle) * scale; let delta = Vec2::new(fam.delta.0, fam.delta.1).rotate(ang) * scale; @@ -360,8 +401,8 @@ pub(crate) fn pattern_lines_until( continue; } let dashes: Vec = fam.dashes.iter().map(|d| d * scale).collect(); - Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out); - if out.len() >= MAX_RUNS || is_set(cancel) { + Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work); + if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) { break; } } @@ -422,7 +463,7 @@ impl Family<'_> { self.origin + p.rotate(self.ang) } - fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>) { + fn sweep(&self, loops: &[Vec], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec>, work: &mut u64) { let local: Vec> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect(); let bb = Bounds2::from_points(local.iter().flatten().copied()); if bb.is_empty() { @@ -456,7 +497,7 @@ impl Family<'_> { let draws = self.dashes.iter().any(|d| *d >= 0.0); let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws); for k in k0..=k1 { - if out.len() >= MAX_RUNS || is_set(cancel) { + if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) { return; } let y = k as f64 * self.spacing; @@ -484,21 +525,22 @@ impl Family<'_> { if solid_line { out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]); } else { - self.dash(x0, x1, y, offset, pat_len, out); + self.dash(x0, x1, y, offset, pat_len, out, work); } } } } /// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`. - fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec>) { + fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec>, work: &mut u64) { let start_rep = ((x0 - offset) / pat_len).floor(); let mut x = offset + start_rep * pat_len; let mut guard = 0; 'outer: while x < x1 { for d in self.dashes { guard += 1; - if guard > 100_000 || out.len() >= MAX_RUNS { + *work = work.saturating_sub(1); + if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 { break 'outer; } let len = d.abs(); @@ -560,3 +602,18 @@ mod tests { assert!((area - 100.0).abs() < 1e-6, "triangulated area must be 100.0, not zero"); } } + +#[cfg(test)] +mod dedup_budget_tests { + use super::*; + #[test] + fn unequal_loops_do_not_spend_the_comparison_budget() { + // The old estimate exhausted the budget on this unequal pair before seeing the triangle. + let a = vec![Vec2::ZERO; 5_000]; + let b = vec![Vec2::ZERO; 5_001]; + let tri = vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)]; + let mut loops = vec![a, b, tri.clone(), tri]; + dedup_polygons(&mut loops); + assert_eq!(loops.len(), 3); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b952fe18..81da8358 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -735,13 +735,17 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou let eb = cadcraft_doc::entity_bounds(d, me, 0); eb.is_empty() || eb.intersects(&win) }; + // One expansion budget for the whole build: seed it with what the enclosing list has already + // spent so a viewport's contents cannot start over with a fresh allowance and exceed the + // documented limit (CWE-400). Both the serial sub-builder and the parallel chunks share it. + let budget = std::sync::atomic::AtomicUsize::new(b.list.expanded); let mut sub = Builder { list: DisplayList::default(), opts: b.opts, plotting: b.plotting, styles: b.styles, named: None, - shared_budget: None, + shared_budget: Some(&budget), transparency: 0, }; #[cfg(not(target_arch = "wasm32"))] @@ -754,7 +758,6 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou // As `build_parallel`: chunks built side by side, joined in drawing order. use rayon::prelude::*; let ents: Vec<&std::sync::Arc> = d.model.iter().collect(); - let budget = std::sync::atomic::AtomicUsize::new(0); let opts = b.opts; let parts: Vec = ents .par_chunks(PARALLEL_CHUNK) diff --git a/crates/render/src/linetype.rs b/crates/render/src/linetype.rs index acd576dc..8a8cab34 100644 --- a/crates/render/src/linetype.rs +++ b/crates/render/src/linetype.rs @@ -94,8 +94,16 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option= 0.0) { + return Some(Vec::new()); + } + // Bound the pattern elements visited, independent of emitted ranges, so a pattern that emits + // little still cannot cycle hundreds of millions of times. + let mut visits: u64 = 0; for d in lt.pattern.iter().map(|d| d.length * scale).cycle() { - if pos >= len || out.len() > 2_000_000 { + visits += 1; + if pos >= len || out.len() > 2_000_000 || visits > 4_000_000 { break; } if d >= 0.0 { @@ -110,6 +118,13 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option Vec> { + coverage(t).unwrap_or_else(|partial| partial) +} + +// No partial coverage may reach owner assignment: it would revisit unbounded merge rectangles. +fn coverage(t: &Table) -> Result>, Vec>> { let rows = t.row_heights.len().min(10_000); let cols = t.col_widths.len().min(10_000); if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS { - return vec![Vec::new(); rows]; + return Err(vec![Vec::new(); rows]); } let mut cov = vec![vec![false; cols]; rows]; + // Coverage marking visits each merge's whole rectangle. Overlapping merges re-mark cells that + // are already covered, so bound the total marking steps to a small multiple of the cell count + // rather than letting overlapping declarations cause quadratic work (CWE-407). + let mut budget: u64 = (rows as u64).saturating_mul(cols as u64).saturating_mul(4).saturating_add(1024); for (r, row) in t.cells.iter().enumerate().take(rows) { for (c, cell) in row.iter().enumerate().take(cols) { if let Some((rs, cs)) = cell.merged { for rr in r..(r + rs.max(1) as usize).min(rows) { for cc in c..(c + cs.max(1) as usize).min(cols) { + budget = budget.saturating_sub(1); + if budget == 0 { + return Err(cov); + } if (rr, cc) != (r, c) && let Some(x) = cov.get_mut(rr).and_then(|v| v.get_mut(cc)) { @@ -74,7 +87,7 @@ pub fn table_covered(t: &Table) -> Vec> { } } } - cov + Ok(cov) } /// The table style a table uses (Standard defaults when missing). @@ -134,19 +147,25 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom { /// One part of a broken table as a table of its own: its rows (with their cell styles) at /// `origin`; merges are cut at the part's edge. fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table { - let cols = t.col_widths.len(); - let mut cells: Vec> = rows.iter().map(|r| t.cells.get(*r).cloned().unwrap_or_default()).collect(); - for (i, row) in cells.iter_mut().enumerate() { - row.resize_with(cols, Default::default); - for cell in row.iter_mut() { - if let Some((rs, cs)) = cell.merged { - // Rows of the merge that follow in this part. - let start = rows.get(i).copied().unwrap_or(0); - let mut n = 1u32; - while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) { - n += 1; + let cols = t.col_widths.len().min(10_000); + let mut cells: Vec> = + rows.iter().take(10_000).map(|r| t.cells.get(*r).map(|row| row.iter().take(cols).cloned().collect()).unwrap_or_default()).collect(); + // Only expand sparse rows to the full column count when detailed layout is actually permitted. + // Otherwise `layout` renders an outline, so materialising millions of empty cells here would be + // wasted work an attacker can trigger with a compact dimension array (CWE-789). + if rows.len().min(10_000).saturating_mul(cols) <= MAX_LAYOUT_CELLS { + for (i, row) in cells.iter_mut().enumerate() { + row.resize_with(cols, Default::default); + for cell in row.iter_mut() { + if let Some((rs, cs)) = cell.merged { + // Rows of the merge that follow in this part. + let start = rows.get(i).copied().unwrap_or(0); + let mut n = 1u32; + while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) { + n += 1; + } + cell.merged = (n > 1 || cs > 1).then_some((n, cs)); } - cell.merged = (n > 1 || cs > 1).then_some((n, cs)); } } } @@ -180,7 +199,8 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom { if rows == 0 || cols == 0 { return g; } - if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS { + let cov = coverage(t); + if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS || cov.is_err() { let (w, h) = (x_at(cols), y_at(rows)); let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)]; for s in corners.windows(2) { @@ -209,7 +229,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom { i }; // Anchors and the anchor owning each grid cell. - let cov = table_covered(t); + let cov = cov.unwrap_or_default(); let mut anchors: Vec = Vec::new(); let mut owner = vec![usize::MAX; rows * cols]; for r in 0..rows { @@ -369,3 +389,37 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) { } } } + +#[cfg(test)] +mod budget_tests { + use super::*; + use cadcraft_doc::TableCell; + + #[test] + fn overlapping_merges_use_outline_fallback() { + let t = Table { + row_heights: vec![1.0; 40], + col_widths: vec![1.0; 40], + cells: vec![vec![TableCell { merged: Some((40, 40)), ..Default::default() }; 40]; 40], + ..Default::default() + }; + assert!(coverage(&t).is_err()); + let g = layout(&Drawing::default(), &t); + assert_eq!(g.lines.len(), 4); + assert!(g.fills.is_empty() && g.texts.is_empty()); + } + + #[test] + fn part_clips_merges_using_displayed_column_count() { + let t = Table { + row_heights: vec![1.0; 102], + col_widths: vec![1.0; 10_001], + cells: vec![vec![TableCell { merged: Some((100, 1)), ..Default::default() }]], + ..Default::default() + }; + let mut rows: Vec<_> = (0..99).collect(); + rows.push(101); + let part = part_table(&t, &rows, Vec2::ZERO); + assert_eq!(part.cells[0][0].merged, Some((99, 1))); + } +}