security: budget the rendering pipeline against DoS geometry (CWE-400/407/789) (#1849)

* security: budget the rendering pipeline against DoS geometry (CWE-400/407/789)

Several render paths let crafted drawing data spin the rendering thread or
allocate without bound:

- Hatch dash sweeping reset its per-span step counter and used emitted geometry
  as its only limit, so gap-only families (which emit nothing) could multiply
  CPU work. Skip families that cannot draw and charge every dash step against a
  shared per-hatch work budget (row 10).
- Hatch polygon deduplication compared loops with an O(n²) cyclic match and
  every loop against every kept one. Charge vertex comparisons against a shared
  budget and stop deduplicating when spent (row 12).
- Table coverage marked every merge rectangle, so overlapping merges re-marked
  covered cells quadratically. Bound marking to a small multiple of the cell
  count (row 11).
- Broken-table part layout expanded sparse rows to full width before checking
  whether detailed layout is allowed. Only expand when within the cell limit;
  otherwise leave cells sparse and let layout draw an outline (row 17).
- Dimension text substitution copied the base for every `<>` placeholder with no
  size bound. Estimate the expansion and cap it at a fixed text budget (row 32).
- Linetype range generation cycled all-gap patterns forever (nothing emitted, so
  the emit limit never tripped). Return continuous for all-gap patterns and
  bound visited pattern elements (row 38).
- Viewport contents built with a fresh expansion counter, so a layout could far
  exceed the documented block-expansion limit. Share one budget across the whole
  build, seeded with what the enclosing list already spent (row 22).

New tests for the all-gap linetype path; all render tests pass, clippy clean.

Addresses findings rows 10, 11, 12, 17, 22, 32, 38.

* fix: preserve bounded render fallbacks and text output

---------

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
This commit is contained in:
Dylan M. Taylor
2026-10-22 17:08:25 -04:00
committed by GitHub
co-authored by skylartaylor
parent 7c72ec45a9
commit 2dbc7699a0
5 changed files with 213 additions and 29 deletions
+57 -2
View File
@@ -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<Vec<Vec2>> {
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);
}
}
+65 -8
View File
@@ -80,18 +80,47 @@ fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool {
}
fn dedup_polygons(loops: &mut Vec<Vec<Vec2>>) {
// `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<Vec2>> = 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<f64> = 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<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>) {
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
let local: Vec<Vec<Vec2>> = 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<Vec<Vec2>>) {
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>, 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);
}
}
+5 -2
View File
@@ -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<Entity>> = d.model.iter().collect();
let budget = std::sync::atomic::AtomicUsize::new(0);
let opts = b.opts;
let parts: Vec<DisplayList> = ents
.par_chunks(PARALLEL_CHUNK)
+16 -1
View File
@@ -94,8 +94,16 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
}
let mut out = Vec::new();
let mut pos = 0.0;
// Gap-only patterns draw nothing, matching `apply`, without walking every repeat.
if !lt.pattern.iter().any(|d| d.length >= 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<V
mod tests {
use super::*;
#[test]
fn an_all_gap_pattern_draws_nothing_without_spinning() {
let lt = Linetype::simple("GAPS", "", &[-0.001, -0.002]);
assert_eq!(ranges(1.0, &lt, 1.0, 0.0), Some(Vec::new()));
assert!(apply(&[Vec2::ZERO, Vec2::new(1.0, 0.0)], &lt, 1.0, 0.0).is_empty());
}
#[test]
fn dashes_split_line() {
let lt = Linetype::simple("DASHED", "", &[0.5, -0.25]);
+70 -16
View File
@@ -53,17 +53,30 @@ pub struct TableGeom {
/// Which cells are covered by a merge (and so not drawn on their own).
pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
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<Vec<bool>>, Vec<Vec<bool>>> {
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<Vec<bool>> {
}
}
}
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<Vec<cadcraft_doc::TableCell>> = 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<Vec<cadcraft_doc::TableCell>> =
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<Anchor> = 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)));
}
}