diff --git a/crates/doc/src/link.rs b/crates/doc/src/link.rs
index 73e4f5ea8..9bb08b90e 100644
--- a/crates/doc/src/link.rs
+++ b/crates/doc/src/link.rs
@@ -9,14 +9,23 @@ pub fn is_web_or_mail_link(url: &str) -> bool {
["http", "https", "mailto"].iter().any(|s| scheme.eq_ignore_ascii_case(s))
}
-/// Whether a hyperlink may be written as an `href` (or Markdown destination) in an exported file:
-/// a web or email link, a bookmark (`#name`) or a relative path. Scripts (`javascript:`,
-/// `vbscript:`), `data:`, `file:` and every other scheme are refused, read the way a browser reads
-/// a URL: ASCII control characters and spaces are dropped first, so `java\nscript:` is a script.
+/// Whether a hyperlink may be written as an `href` (or Markdown destination, RTF `HYPERLINK`
+/// field, ODT `xlink:href`, LaTeX `\href`) in an exported file: a web or email link, a bookmark
+/// (`#name`) or a relative path. Scripts (`javascript:`, `vbscript:`), `data:`, `file:` and every
+/// other scheme are refused, read the way a browser reads a URL: ASCII control characters and
+/// spaces are dropped first, so `java\nscript:` is a script. A protocol-relative reference
+/// (`//host/…`) or a UNC path (`\\host\…`, or `//host` once backslashes are read as separators)
+/// names another machine — following it on Windows leaks the user's NTLM credentials — so it is
+/// refused too.
pub fn is_safe_href(url: &str) -> bool {
+ let cleaned: String = url.chars().filter(|c| !c.is_ascii_control() && *c != ' ').collect();
+ // `//host` (protocol-relative) and `\\host` (UNC), with `\` read as `/` the way Windows does.
+ if cleaned.replace('\\', "/").starts_with("//") {
+ return false;
+ }
match scheme(url) {
Some(s) => matches!(s.as_str(), "http" | "https" | "mailto"),
- None => !url.trim().is_empty(),
+ None => !cleaned.is_empty(),
}
}
@@ -73,6 +82,11 @@ mod tests {
"data:text/html,",
"file:///etc/passwd",
"ms-msdt:/id",
+ // Protocol-relative and UNC references name another machine (NTLM leak on Windows).
+ "//attacker.example/share/x",
+ "\\\\attacker.example\\share\\x",
+ "/\\attacker.example\\x",
+ " //attacker.example/x",
"",
] {
assert!(!is_safe_href(bad), "{bad:?}");
diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs
index 3da61275c..e076ec2be 100644
--- a/crates/docbin/src/lib.rs
+++ b/crates/docbin/src/lib.rs
@@ -44,6 +44,10 @@ use table::ParaOut;
const MAX_STREAM: u64 = 1 << 30;
/// Most header/footer parts we create (matches the docx reader's cap).
const MAX_PARTS: usize = 50_000;
+/// Hard ceiling on characters materialised across every story walk of one document (body,
+/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
+/// WordDocument stream (see [`read`]); this caps even a maximal stream.
+const MAX_TOTAL_CHARS: u64 = 64 << 20;
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
const C_PIC_LOCATION: u16 = 0x6A03;
/// `sprmPIlvl` / `sprmPIlfo`: the paragraph's list level and list.
@@ -158,6 +162,12 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result) -> Result {
/// Where sections end (main-document CPs, sorted): the section mark (0x0C) just before
/// each ends the section's last paragraph, it isn't a page break inside it.
sect_ends: &'a [u32],
+ /// Characters still allowed across every story walk of this document. Story ranges (headers,
+ /// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
+ /// them could otherwise materialise the same text thousands of times; this bounds the total.
+ budget: &'a std::cell::Cell,
}
/// State while assembling one paragraph from per-CP formatting.
@@ -385,6 +400,12 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
continue;
}
};
+ // Every real character counts against the document-wide budget; once it is spent, stop
+ // (a hostile file with thousands of story ranges over the same text can't multiply on).
+ if ctx.budget.get() == 0 {
+ break;
+ }
+ ctx.budget.set(ctx.budget.get() - 1);
// A section mark (0x0C) at a section's end is that section's last paragraph mark.
let section_mark = c == '\u{C}' && ctx.sect_ends.binary_search(&cp.saturating_add(1)).is_ok();
match c {
diff --git a/crates/docx/src/read/freeform.rs b/crates/docx/src/read/freeform.rs
index 95b224acc..d83521f53 100644
--- a/crates/docx/src/read/freeform.rs
+++ b/crates/docx/src/read/freeform.rs
@@ -7,6 +7,10 @@ use crate::xml::El;
/// Straight pieces a Bézier curve is flattened into.
const CURVE_STEPS: usize = 8;
+/// Points read from one path segment. A conformant `a:lnTo`/`a:moveTo` has one, `a:quadBezTo`
+/// two and `a:cubicBezTo` three; the cap bounds the de Casteljau control polygon so a segment
+/// stuffed with points can't make curve flattening quadratic in the point count.
+const MAX_SEG_POINTS: usize = 8;
/// Largest path coordinate kept, in EMUs.
const MAX_EMU: f64 = 1.0e12;
@@ -41,7 +45,7 @@ pub fn cust_geom(sppr: &El, w: f32, h: f32) -> Option {
if cur.len() >= MAX_POINTS || done.len() >= MAX_PATHS {
break;
}
- let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).collect();
+ let pts: Vec<[f64; 2]> = seg.children("a:pt").filter_map(pt).take(MAX_SEG_POINTS).collect();
match seg.local() {
"moveTo" => {
if !cur.is_empty() {
@@ -118,3 +122,36 @@ fn bezier(c: &[[f64; 2]], t: f64) -> [f64; 2] {
}
p.first().copied().unwrap_or([0.0, 0.0])
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sppr(seg: &str) -> crate::xml::El {
+ let xml = format!(
+ "\
+ \
+ {seg}"
+ );
+ crate::xml::parse(xml.as_bytes()).expect("parse spPr")
+ }
+
+ #[test]
+ fn a_bezier_segment_with_many_points_is_bounded() {
+ // One cubicBezTo stuffed with 50 000 points must not make de Casteljau quadratic: the
+ // control polygon is capped, so flattening stays a handful of points and returns at once.
+ let pts = "".repeat(50_000);
+ let sppr = sppr(&format!("{pts}"));
+ let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
+ assert!(f.paths.iter().all(|p| p.pts.len() <= 1 + CURVE_STEPS), "{:?}", f.paths.iter().map(|p| p.pts.len()).collect::>());
+ }
+
+ #[test]
+ fn a_conformant_cubic_still_flattens_to_a_curve() {
+ let sppr = sppr("");
+ let f = cust_geom(&sppr, 100.0, 100.0).expect("a path");
+ assert_eq!(f.paths.len(), 1);
+ assert!(f.paths[0].pts.len() >= 2 && f.paths[0].pts.len() <= 1 + CURVE_STEPS);
+ }
+}
diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs
index 536a46542..0c6c975b1 100644
--- a/crates/formats/src/html.rs
+++ b/crates/formats/src/html.rs
@@ -824,7 +824,10 @@ impl<'r> Builder<'r> {
t.rows.push(r);
}
let borderless = !t.bordered;
- let mut ft = FTable { rows: t.rows.into_iter().filter(|r| !r.is_empty()).collect(), widths: Vec::new(), borderless };
+ // Keep at most MAX_ROWS rows (the model drops the rest anyway): bounds the
+ // O(rows × rowspan) work of `insert_covered` on a table of millions of ``.
+ let rows: Vec<_> = t.rows.into_iter().filter(|r| !r.is_empty()).take(wordcraft_doc::table::MAX_ROWS).collect();
+ let mut ft = FTable { rows, widths: Vec::new(), borderless };
if !ft.rows.is_empty() {
ft.insert_covered();
self.container().push(FBlock::Table(ft));
diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs
index d9e799046..c7c555b85 100644
--- a/crates/formats/src/latex.rs
+++ b/crates/formats/src/latex.rs
@@ -317,8 +317,12 @@ impl Writer {
}
if let Some(l) = f.link.as_deref().filter(|l| !l.is_empty()) {
s = match l.strip_prefix('#') {
+ // An internal bookmark reference, not a URL.
Some(target) => format!("\\hyperlink{{{}}}{{{s}}}", escape_url(target)),
- None => format!("\\href{{{}}}{{{s}}}", escape_url(l)),
+ // A script, `file:`, UNC or other non-web target becomes plain text: a `\href`
+ // compiles to a PDF `/URI` action the reader would otherwise follow on click.
+ None if wordcraft_doc::link::is_safe_href(l) => format!("\\href{{{}}}{{{s}}}", escape_url(l)),
+ None => s,
};
}
s
diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs
index 38d187fdc..e23f13895 100644
--- a/crates/formats/src/model.rs
+++ b/crates/formats/src/model.rs
@@ -265,6 +265,12 @@ impl FTable {
/// After an importer that gives `rowspan` but no placeholder cells (HTML): insert covered
/// cells into the rows below each spanning cell.
pub fn insert_covered(&mut self) {
+ // Each spanning cell touches up to `rowspan - 1` rows below it; a table of many rows each
+ // carrying a large rowspan would otherwise be O(rows × rowspan) covered cells, each of
+ // which becomes a document cell downstream. Cap the total touched so a small file can't
+ // multiply into a huge table, whatever the rowspans claim.
+ const MAX_COVERED_WORK: usize = 1 << 17;
+ let mut work = 0usize;
let nrows = self.rows.len();
for r in 0..nrows {
let mut g = 0usize;
@@ -273,6 +279,10 @@ impl FTable {
let (span, rs, covered) = (c.colspan.clamp(1, 63) as usize, c.rowspan.clamp(1, 1000) as usize, c.covered);
if rs > 1 && !covered {
for k in 1..rs {
+ work += 1;
+ if work > MAX_COVERED_WORK {
+ return;
+ }
let Some(row) = self.rows.get_mut(r + k) else { break };
// Find the index in that row at grid column `g`.
let mut x = 0usize;
@@ -603,7 +613,10 @@ impl<'a> Builder<'a> {
}
let cols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS);
let width = self.doc.last_section.text_width().max(72.0);
- let mut tb = Table::new(rows.len(), cols, width);
+ // One template row for the grid widths and props; the real rows are built below. Sizing it
+ // to `rows.len()` would allocate a full rows × cols cell grid only to clear it, which a
+ // tall table (many rows, wide from merged cells) turns into a large transient spike.
+ let mut tb = Table::new(1, cols, width);
if t.borderless {
tb.props.style = None;
}
@@ -1021,6 +1034,21 @@ pub fn image_px(data: &[u8]) -> Option<(u32, u32)> {
image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?.into_dimensions().ok()
}
+/// Largest allocation one decoded picture may make when it is re-encoded on export: the `image`
+/// crate's default, stated here so the bound doesn't depend on a library default that may change.
+const MAX_DECODE_ALLOC: u64 = 512 << 20;
+
+/// Decode an encoded image with a bounded allocation, so a small file that declares huge
+/// dimensions can't exhaust memory when a picture is re-encoded on export. `None` when it would
+/// be too large or doesn't decode.
+pub fn decode_bounded(data: &[u8]) -> Option {
+ let mut reader = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?;
+ let mut limits = image::Limits::default();
+ limits.max_alloc = Some(MAX_DECODE_ALLOC);
+ reader.limits(limits);
+ reader.decode().ok()
+}
+
/// A picture from bytes with an optional display size (points); the natural size (96 dpi) is
/// used otherwise, shrunk to fit a 6.5" column.
pub fn make_img(data: impl Into>>, w: Option, h: Option, alt: &str) -> Option
{
@@ -1225,6 +1253,16 @@ pub fn parse_color(s: &str) -> Option {
mod tests {
use super::*;
+ #[test]
+ fn insert_covered_is_bounded_on_hostile_rowspan() {
+ // Many rows each carrying the maximum rowspan would be O(rows × rowspan) covered cells;
+ // the work cap must keep insertion bounded (and quick) instead of exhausting memory.
+ let mut t =
+ FTable { rows: (0..20_000).map(|_| vec![Cell { rowspan: 1000, ..Default::default() }]).collect(), widths: Vec::new(), borderless: false };
+ t.insert_covered();
+ assert!(t.rows.iter().all(|r| r.len() <= 64), "a row grew past the column cap");
+ }
+
#[test]
fn base64_round_trip() {
for data in [&b""[..], b"f", b"fo", b"foo", b"foob", b"fooba", b"foobar", &[0, 255, 128, 7]] {
diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs
index 3c6491b03..4e315010d 100644
--- a/crates/formats/src/odt.rs
+++ b/crates/formats/src/odt.rs
@@ -357,7 +357,9 @@ impl Writer {
flush(&mut body, &mut spaces, prev_space);
prev_space = true;
}
- let link = f.link.as_ref();
+ // A script, `file:`, UNC or other non-web target is written as plain text, as
+ // the HTML and Markdown exporters do (LibreOffice follows `file:`/UNC hrefs).
+ let link = f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l));
if let Some(l) = link {
out.push_str(&format!("", x(l)));
}
diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs
index 53dc89e04..d358179f4 100644
--- a/crates/formats/src/rtf.rs
+++ b/crates/formats/src/rtf.rs
@@ -173,7 +173,10 @@ impl Writer {
ctl.push_str(&format!("\\chcbpat{i}"));
}
let body = if ctl.is_empty() { esc(t) } else { format!("{{{ctl} {}}}", esc(t)) };
- match &f.link {
+ // A script, `file:`, UNC or other non-web target is written as plain text, as the HTML and
+ // Markdown exporters do: Word follows a HYPERLINK field's target on click, and a UNC one
+ // leaks the reader's NTLM credentials.
+ match f.link.as_deref().filter(|l| wordcraft_doc::link::is_safe_href(l)) {
Some(l) => {
out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&l.replace('"', "%22")), body));
}
@@ -186,8 +189,9 @@ impl Writer {
"png" => (img.data.to_vec(), "\\pngblip"),
"jpeg" | "jpg" => (img.data.to_vec(), "\\jpegblip"),
_ => {
- // Re-encode other formats as PNG.
- let Ok(dec) = image::load_from_memory(&img.data) else { return };
+ // Re-encode other formats as PNG, bounding the decode so a tiny file declaring
+ // huge dimensions can't exhaust memory on export.
+ let Some(dec) = model::decode_bounded(&img.data) else { return };
let mut buf = Vec::new();
if dec.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).is_err() {
return;
diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs
index 2278cfaae..efa2f3c5d 100644
--- a/crates/layout/src/lib.rs
+++ b/crates/layout/src/lib.rs
@@ -3456,6 +3456,13 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
// A table in the text flow starts below the floating objects in its way.
below_floats(pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
let header_rows: Vec = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
+ // Header rows are re-placed on every page a table spills onto, so a table with many header
+ // rows that each spill onto their own page is O(header rows × pages) placed items — a few KB
+ // of document can otherwise fill memory. Once this many header rows have been repeated across
+ // the whole table, later pages carry none; pagination still finishes. Far above any real
+ // table (e.g. 3 header rows over 30 000 pages).
+ const MAX_HEADER_REPEATS: usize = 200_000;
+ let mut header_repeats = 0usize;
let place = |pb: &mut PageBuilder, row: &table::RowLayout| {
let (x, y) = (pb.col_x() + tl.x, pb.y);
if let Some(pg) = pb.page() {
@@ -3497,11 +3504,15 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
None => {}
}
pb.advance(block, body_top);
- // Repeat header rows.
- if !header_rows.contains(&ri) {
+ // Repeat header rows, up to this table's cap.
+ if !header_rows.contains(&ri) && header_repeats < MAX_HEADER_REPEATS {
for hr in &header_rows {
+ if header_repeats >= MAX_HEADER_REPEATS {
+ break;
+ }
if let Some(h) = tl.rows.get(*hr) {
place(pb, h);
+ header_repeats += 1;
}
}
}
diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs
index 1cb097fc3..e73ccb8e5 100644
--- a/crates/pdf/src/lib.rs
+++ b/crates/pdf/src/lib.rs
@@ -1641,7 +1641,15 @@ fn path_from(b: &kurbo::BezPath) -> Option {
/// can't fail the whole export).
fn load_image(data: &Arc>) -> Option {
let fmt = image::guess_format(data).ok()?;
- let dec = image::load_from_memory_with_format(data, fmt).ok()?;
+ // The side check below used to run only after a full decode; applying it as a decode limit
+ // rejects a small file that declares a huge image at header time instead. The default
+ // allocation cap (512 MB, what `load_from_memory_with_format` applied) stays in force.
+ let mut reader = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?;
+ let mut limits = image::Limits::default();
+ limits.max_image_width = Some(MAX_IMAGE_SIDE);
+ limits.max_image_height = Some(MAX_IMAGE_SIDE);
+ reader.limits(limits);
+ let dec = reader.decode().ok()?;
let (w, h) = (dec.width(), dec.height());
if w == 0 || h == 0 || w > MAX_IMAGE_SIDE || h > MAX_IMAGE_SIDE {
return None;
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
index bd7ef50b7..972c3c638 100644
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -167,9 +167,20 @@ fn vectors() -> &'static Mutex>>> {
C.get_or_init(|| Mutex::new(MediaCache { map: HashMap::new(), bytes: 0 }))
}
-/// Decode an encoded image to a premultiplied pixmap.
+/// Largest side (pixels) a document picture may decode to: what a `Pixmap` can hold (`u16`
+/// sides, which [`premul_pixmap`] enforces after decoding). Applying it as a decode limit rejects
+/// a small file that declares a bigger image at header time, before anything is allocated.
+const MAX_DECODE_SIDE: u32 = u16::MAX as u32;
+
+/// Decode an encoded image to a premultiplied pixmap. The dimension limit above and the default
+/// allocation cap (512 MB, what `load_from_memory` applied) bound the decode during paint.
pub fn decode_pixmap(bytes: &[u8]) -> Option {
- let img = image::load_from_memory(bytes).ok()?.to_rgba8();
+ let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
+ let mut limits = image::Limits::default();
+ limits.max_image_width = Some(MAX_DECODE_SIDE);
+ limits.max_image_height = Some(MAX_DECODE_SIDE);
+ reader.limits(limits);
+ let img = reader.decode().ok()?.to_rgba8();
let (w, h) = img.dimensions();
premul_pixmap(w, h, img.as_raw())
}