From 5155d2c18c0d482377cb222c06fef64f6ea28ca5 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:39:46 +0100 Subject: [PATCH] #3703: WOFF and WOFF2 sizes are checked before the decoder allocates from them Findings 39 and 70. 39: wuff inflates each WOFF 1.0 table into a buffer sized from the directory's origLength, before anything checks it against our 128 MB cap, so a tiny file could ask for gigabytes. The directory's table sizes are now summed first and a file over MAX_DECODED_BYTES is TooLarge. 70: wuff's WOFF2 glyf reconstruction allocates a glyph's points from its nPoints counts and checks the 65536-point limit only afterwards. The brotli stream is now decompressed by us (brotli-decompressor, already in the tree through wuff) and every transformed glyf table's per-glyph point totals are checked before wuff unpacks them; a glyph over the limit (which wuff would refuse anyway) is refused up front. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 2 + crates/fonts/Cargo.toml | 1 + crates/fonts/src/fontdb/user.rs | 214 ++++++++++++++++++++++++++++++-- 4 files changed, 211 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfcbb47b5..e88ebb28c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6755,6 +6755,7 @@ dependencies = [ name = "wordcraft-fonts" version = "0.11.0" dependencies = [ + "brotli-decompressor", "harfrust 0.13.3", "kurbo", "log", diff --git a/Cargo.toml b/Cargo.toml index 165eeb1bb..6bdbf2ef9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ harfrust = "0.13" # WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders # (brotli-decompressor, flate2) are already in the tree. wuff = "0.2.9" +# WOFF2's brotli stream, decoded ahead of wuff so its glyph point counts are checked first (#3703). +brotli-decompressor = "5" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 9107da44f..8c0322a7d 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -13,6 +13,7 @@ kurbo = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } wuff = { workspace = true } +brotli-decompressor = { workspace = true } unicode-bidi = { workspace = true } unicode-script = { workspace = true } log = { workspace = true } diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index bc7ce6a6d..7e0ead7ed 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -149,8 +149,24 @@ pub fn decode_font_file(bytes: &[u8]) -> Result, UserFontError> { } let decoded = match sniff(bytes).ok_or(UserFontError::NotAFont)? { FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection => return Ok(bytes.to_vec()), - FontFormat::Woff => unwrap_web_font(|| wuff::decompress_woff1(bytes))?, - FontFormat::Woff2 => unwrap_web_font(|| wuff::decompress_woff2(bytes))?, + FontFormat::Woff => { + check_woff1_size(bytes)?; + unwrap_web_font(|| wuff::decompress_woff1(bytes))? + } + FontFormat::Woff2 => { + let mut too_many_points = false; + let unwrapped = unwrap_web_font(|| { + wuff::decompress_woff2_with_custom_brotli(bytes, &mut |data, size| { + let stream = unbrotli(data, size)?; + too_many_points = !woff2_points_fit(bytes, &stream); + if too_many_points { Err(TOO_MANY_POINTS.into()) } else { Ok(stream) } + }) + }); + if too_many_points { + return Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into())); + } + unwrapped? + } }; if decoded.len() > MAX_DECODED_BYTES { return Err(UserFontError::TooLarge { bytes: decoded.len() }); @@ -161,6 +177,126 @@ pub fn decode_font_file(bytes: &[u8]) -> Result, UserFontError> { } } +/// WOFF 1.0: the font its table directory describes must fit [`MAX_DECODED_BYTES`]. The decoder +/// sizes each table's buffer from the directory's `origLength` before inflating it (#3703). +/// A short header or directory is left to the decoder to report. +fn check_woff1_size(bytes: &[u8]) -> Result<(), UserFontError> { + let u32_at = |at: usize| bytes.get(at..at.saturating_add(4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + let tables = bytes.get(12..14).and_then(|n| n.try_into().ok()).map_or(0, |n| usize::from(u16::from_be_bytes(n))); + let mut total = 12 + 16 * tables; + for i in 0..tables { + let entry = 44 + 20 * i; + let (Some(compressed), Some(orig)) = (u32_at(entry + 8), u32_at(entry + 12)) else { break }; + total = total.saturating_add((compressed.max(orig) as usize).saturating_add(3) & !3); + } + if total > MAX_DECODED_BYTES { + return Err(UserFontError::TooLarge { bytes: total }); + } + Ok(()) +} + +/// Why a WOFF2 file whose glyph has more points than a glyph may is refused. +const TOO_MANY_POINTS: &str = "a glyph has more than 65536 points"; + +/// WOFF2's brotli stream, which must decompress to exactly `size` bytes (the decoder has checked +/// `size` against its own cap and the compression ratio). +fn unbrotli(data: &[u8], size: usize) -> Result, Box> { + use std::io::Read; + let mut out = Vec::with_capacity(size); + brotli_decompressor::Decompressor::new(data, 4096).take(size as u64 + 1).read_to_end(&mut out)?; + if out.len() != size { + return Err("the compressed data doesn't have the size the directory gives".into()); + } + Ok(out) +} + +/// Does every glyph in the WOFF2 file's transformed `glyf` tables have at most 65536 points, as +/// the decoder requires? It allocates a glyph's points from its counts before checking them, so a +/// small file could claim gigabytes (#3703). `stream` is the decompressed table data; anything +/// that doesn't parse is refused too (the decoder would refuse it). +fn woff2_points_fit(file: &[u8], stream: &[u8]) -> bool { + woff2_transformed_glyf(file, stream).is_some_and(|tables| tables.iter().all(|t| glyf_points_fit(t).unwrap_or(false))) +} + +/// The transformed `glyf` tables in `stream`, found from the WOFF2 table directory in `file` +/// (tables follow each other in directory order). +fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option> { + let tables = u16::from_be_bytes(file.get(12..14)?.try_into().ok()?); + let (mut at, mut offset, mut glyf) = (48usize, 0usize, Vec::new()); + for _ in 0..tables { + let flags = *file.get(at)?; + at += 1; + let tag: Option<&[u8]> = if flags & 63 == 63 { + at += 4; + Some(file.get(at - 4..at)?) + } else { + None + }; + let is = |index: u8, name: &[u8]| flags & 63 == index || tag == Some(name); + let (is_glyf, is_loca) = (is(10, b"glyf"), is(11, b"loca")); + let orig = base128(file, &mut at)?; + // glyf and loca are transformed at version 0, other tables at any other version. + let transformed = if is_glyf || is_loca { flags >> 6 == 0 } else { flags >> 6 != 0 }; + let len = if transformed { base128(file, &mut at)? } else { orig }; + let end = offset.checked_add(len)?; + if is_glyf && transformed { + glyf.push(stream.get(offset..end)?); + } + offset = end; + } + Some(glyf) +} + +/// WOFF2's UIntBase128. +fn base128(b: &[u8], at: &mut usize) -> Option { + let mut v: u32 = 0; + for i in 0..5 { + let byte = *b.get(*at)?; + *at += 1; + if (i == 0 && byte == 0x80) || v >> 25 != 0 { + return None; + } + v = v << 7 | u32::from(byte & 0x7f); + if byte & 0x80 == 0 { + return usize::try_from(v).ok(); + } + } + None +} + +/// Does every glyph of the transformed `glyf` table `t` have at most 65536 points? +fn glyf_points_fit(t: &[u8]) -> Option { + let u32_at = |at: usize| t.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes).map(|v| v as usize); + let glyphs = usize::from(u16::from_be_bytes(t.get(4..6)?.try_into().ok()?)); + let contours_end = 36usize.checked_add(u32_at(8)?)?; + let contours = t.get(36..contours_end)?; + let points = t.get(contours_end..contours_end.checked_add(u32_at(12)?)?)?; + let mut at = 0; + for g in 0..glyphs { + let n = i16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?); + let mut total = 0usize; + for _ in 0..n.max(0) { + // 255UInt16 + let code = *points.get(at)?; + at += 1; + let mut byte = || { + at += 1; + points.get(at - 1).copied().map(usize::from) + }; + total += match code { + 253 => byte()? << 8 | byte()?, + 254 => byte()? + 253 * 2, + 255 => byte()? + 253, + v => usize::from(v), + }; + if total > 1 << 16 { + return Some(false); + } + } + } + Some(true) +} + /// Run a WOFF/WOFF2 decoder. The decoder checks its input, but it is third-party code reading /// hostile bytes: a panic in it is caught (native) and reported as a damaged file. fn unwrap_web_font(decode: impl FnOnce() -> Result, wuff::WuffErr>) -> Result, UserFontError> { @@ -564,25 +700,89 @@ pub(crate) mod tests { base128(data.len() as u32, &mut dir); stream.extend_from_slice(data); } - let compressed = brotli_stored(&stream); + let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::(); + woff2_file(&font[..4], t.len(), &dir, &stream, sfnt) + } + + /// A WOFF2 file: header, the table directory `dir` of `n` tables, `stream` in stored brotli blocks. + fn woff2_file(flavor: &[u8], n: usize, dir: &[u8], stream: &[u8], sfnt: usize) -> Vec { + let compressed = brotli_stored(stream); let mut out = Vec::new(); out.extend_from_slice(b"wOF2"); - out.extend_from_slice(&font[..4]); + out.extend_from_slice(flavor); let total = (48 + dir.len() + compressed.len()).next_multiple_of(4); out.extend_from_slice(&(total as u32).to_be_bytes()); - out.extend_from_slice(&(t.len() as u16).to_be_bytes()); + out.extend_from_slice(&(n as u16).to_be_bytes()); out.extend_from_slice(&0u16.to_be_bytes()); - let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::(); out.extend_from_slice(&(sfnt as u32).to_be_bytes()); out.extend_from_slice(&(compressed.len() as u32).to_be_bytes()); out.extend_from_slice(&[0, 1, 0, 0]); out.extend_from_slice(&[0; 20]); - out.extend(dir); + out.extend_from_slice(dir); out.extend(compressed); out.resize(total, 0); out } + /// A WOFF2 file holding a transformed `glyf` (and `loca`) whose one glyph has two contours of + /// `points` points each (and no coordinates: the decoder finds it damaged in the end). + fn woff2_glyph_with(points: u16) -> Vec { + let total = 2 * points as usize; + let mut glyf = vec![0, 0, 0, 0, 0, 1, 0, 0]; // reserved, optionFlags, numGlyphs 1, indexFormat + // nContour, nPoints, flag, glyph, composite, bbox and instruction stream sizes. + for size in [2, 6, total, 0, 0, 4, 0] { + glyf.extend_from_slice(&(size as u32).to_be_bytes()); + } + glyf.extend_from_slice(&2i16.to_be_bytes()); + let [hi, lo] = points.to_be_bytes(); + glyf.extend_from_slice(&[253, hi, lo, 253, hi, lo]); // 255UInt16 in its word form + glyf.resize(glyf.len() + total, 1); // on-curve flags + glyf.extend_from_slice(&[0; 4]); // no explicit bounding boxes + let mut dir = vec![10]; // glyf, transform version 0 + base128(4096, &mut dir); + base128(glyf.len() as u32, &mut dir); + dir.push(11); // loca, transformed away + base128(8, &mut dir); + base128(0, &mut dir); + woff2_file(&[0, 1, 0, 0], 2, &dir, &glyf, 12 + 32 + 4096 + 8) + } + + /// A WOFF 1.0 file whose one table is a zlib stream of nothing that claims to inflate to `orig` + /// bytes. + fn woff1_claiming(orig: u32) -> Vec { + let mut out = b"wOFF\0\x01\0\0".to_vec(); + out.extend_from_slice(&72u32.to_be_bytes()); // length + out.extend_from_slice(&1u16.to_be_bytes()); // numTables + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&orig.saturating_add(28).to_be_bytes()); // totalSfntSize + out.extend_from_slice(&[0, 1, 0, 0]); + out.extend_from_slice(&[0; 20]); + out.extend_from_slice(b"glyf"); + for v in [64, 8, orig, 0] { + out.extend_from_slice(&u32::to_be_bytes(v)); // offset, compLength, origLength, checksum + } + out.extend_from_slice(&[0x78, 0x9c, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]); + out + } + + #[test] + fn a_woff_claiming_a_huge_table_is_refused_before_inflating() { + // #3703: the decoder sizes a table's buffer from the directory before inflating it. + for orig in [200 << 20, u32::MAX] { + assert!(matches!(decode_font_file(&woff1_claiming(orig)), Err(UserFontError::TooLarge { .. })), "{orig}"); + } + // A small claim reaches the decoder, which finds the data doesn't inflate to it. + assert!(matches!(decode_font_file(&woff1_claiming(64)), Err(UserFontError::BadWebFont(_)))); + } + + #[test] + fn a_woff2_glyph_with_too_many_points_is_refused_before_unpacking() { + // #3703: the decoder allocates a glyph's points from these counts before checking them. + assert_eq!(decode_font_file(&woff2_glyph_with(40_000)), Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into()))); + // Within the limit the scan passes the file on, to the decoder. + assert!(matches!(decode_font_file(&woff2_glyph_with(3)), Err(UserFontError::BadWebFont(e)) if e != TOO_MANY_POINTS)); + } + #[test] fn an_added_font_is_listed_resolved_and_measured() { let db = db();