From e00ff04d4a4d1de8d9c1412b4e8db98915a4f6b8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:24:55 -0500 Subject: [PATCH] Fonts: refuse WOFF2 collections whose rebuilt directories are huge or list a table twice (#5332) The WOFF2 decoder rebuilds every collection font's table directory (16 bytes per listed table) before checking its output size, while the file spends as little as one byte per listed table. A small collection listing the same tables over and over made it allocate hundreds of megabytes twice. Preflight the collection directory before decoding: the rebuilt directories must fit 16 MiB and no font may list a table twice. --- crates/fonts/src/fontdb/user.rs | 118 +++++++++++++++++++++----- crates/fonts/src/fontdb/user/tests.rs | 47 ++++++++++ 2 files changed, 145 insertions(+), 20 deletions(-) diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index 0c8a0f6952..03f449cdb7 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -154,6 +154,7 @@ pub fn decode_font_file(bytes: &[u8]) -> Result, UserFontError> { unwrap_web_font(|| wuff::decompress_woff1(bytes))? } FontFormat::Woff2 => { + check_woff2_collection(bytes)?; let mut too_many_points = false; let unwrapped = unwrap_web_font(|| { wuff::decompress_woff2_with_custom_brotli(bytes, &mut |data, size| { @@ -221,8 +222,29 @@ fn woff2_points_fit(file: &[u8], stream: &[u8]) -> bool { /// 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, _) = woff2_directory(file)?; + let (mut offset, mut glyf) = (0usize, Vec::new()); + for t in tables { + let end = offset.checked_add(t.len)?; + if t.transformed_glyf { + glyf.push(stream.get(offset..end)?); + } + offset = end; + } + Some(glyf) +} + +/// A WOFF2 table directory entry: whether it is a transformed `glyf` table, and its length in +/// the decompressed stream. +struct Woff2Table { + transformed_glyf: bool, + len: usize, +} + +/// The WOFF2 table directory in `file`, and where it ends (the collection directory follows). +fn woff2_directory(file: &[u8]) -> Option<(Vec, usize)> { let tables = u16::from_be_bytes(file.get(12..14)?.try_into().ok()?); - let (mut at, mut offset, mut glyf) = (48usize, 0usize, Vec::new()); + let (mut at, mut out) = (48usize, Vec::with_capacity(usize::from(tables))); for _ in 0..tables { let flags = *file.get(at)?; at += 1; @@ -238,13 +260,81 @@ fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option> 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; + out.push(Woff2Table { transformed_glyf: is_glyf && transformed, len }); } - Some(glyf) + Some((out, at)) +} + +/// Largest table directories a WOFF2 collection may rebuild, in bytes. Each font's directory is +/// 16 bytes per table it lists, while the file spends as little as one byte per listed table, so +/// a collection listing the same tables over and over rebuilt hundreds of megabytes of +/// directories (twice: built, then copied into the font) before the decoder's output cap was +/// checked. Real collections need kilobytes. +const MAX_WOFF2_COLLECTION_HEADER: usize = 16 << 20; + +/// Why a WOFF2 collection whose directories are too large, or list a table twice, is refused. +const BAD_COLLECTION: &str = "the collection's font directories are too large or list a table twice"; + +/// WOFF2 collections: the table directories the decoder rebuilds for its fonts must fit +/// [`MAX_WOFF2_COLLECTION_HEADER`], and no font may list a table twice (OpenType allows one +/// table per tag in a font). Checked before decoding, since the decoder builds them before it +/// checks its output size. A directory that doesn't parse is left to the decoder to report. +fn check_woff2_collection(file: &[u8]) -> Result<(), UserFontError> { + if file.get(4..8) != Some(b"ttcf".as_slice()) { + return Ok(()); + } + match woff2_collection_fits(file) { + Some(false) => Err(UserFontError::BadWebFont(BAD_COLLECTION.into())), + _ => Ok(()), + } +} + +/// Do the collection's rebuilt table directories fit, with no table listed twice in one font? +/// None when the directories don't parse. +fn woff2_collection_fits(file: &[u8]) -> Option { + let (tables, mut at) = woff2_directory(file)?; + let version = u32::from_be_bytes(file.get(at..at.checked_add(4)?)?.try_into().ok()?); + at += 4; + let fonts = read_255_u16(file, &mut at)?; + // TTC header (with the DSIG fields of version 2) and one offset per font. + let dsig = if version == 0x0002_0000 { 12 } else { 0 }; + let mut size = 12 + dsig + 4 * fonts; + let mut indices: Vec = Vec::new(); + for _ in 0..fonts { + let n = read_255_u16(file, &mut at)?; + size = size.saturating_add(12 + 16 * n); + if size > MAX_WOFF2_COLLECTION_HEADER { + return Some(false); + } + at = at.checked_add(4)?; // flavor + indices.clear(); + for _ in 0..n { + let i = read_255_u16(file, &mut at)?; + if i >= tables.len() { + return None; + } + indices.push(i); + } + indices.sort_unstable(); + if indices.windows(2).any(|w| w[0] == w[1]) { + return Some(false); + } + } + Some(true) +} + +/// WOFF2's 255UInt16. +fn read_255_u16(b: &[u8], at: &mut usize) -> Option { + let mut byte = || { + *at += 1; + b.get(*at - 1).copied().map(usize::from) + }; + Some(match byte()? { + 253 => byte()? << 8 | byte()?, + 254 => byte()? + 253 * 2, + 255 => byte()? + 253, + v => v, + }) } /// WOFF2's UIntBase128. @@ -278,19 +368,7 @@ fn glyf_points_fit(t: &[u8]) -> Option { let n = u16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?); let mut total = 0usize; for _ in 0..if n == 0xFFFF { 0 } else { n } { - // 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), - }; + total += read_255_u16(points, &mut at)?; if total > 1 << 16 { return Some(false); } diff --git a/crates/fonts/src/fontdb/user/tests.rs b/crates/fonts/src/fontdb/user/tests.rs index cc3089b6d1..a8673e5a1e 100644 --- a/crates/fonts/src/fontdb/user/tests.rs +++ b/crates/fonts/src/fontdb/user/tests.rs @@ -455,3 +455,50 @@ fn fonts_stored_at_once_under_one_name_each_get_their_own_file() { } let _ = std::fs::remove_dir_all(&dir); } + +/// A WOFF2 collection of `tables` untransformed tables whose fonts list the tables in `fonts`. +fn woff2_collection(tables: usize, fonts: &[Vec]) -> Vec { + fn u255(v: u16, out: &mut Vec) { + match v { + 0..=252 => out.push(v as u8), + _ => { + out.push(253); + out.extend_from_slice(&v.to_be_bytes()); + } + } + } + let mut dir = Vec::new(); + for i in 0..tables { + dir.push(63); + dir.extend_from_slice(&(i as u32).to_be_bytes()); // a made-up tag per table + base128(4, &mut dir); + } + dir.extend_from_slice(&0x0001_0000u32.to_be_bytes()); + u255(fonts.len() as u16, &mut dir); + for font in fonts { + u255(font.len() as u16, &mut dir); + dir.extend_from_slice(&[0, 1, 0, 0]); + for &i in font { + u255(i, &mut dir); + } + } + woff2_file(b"ttcf", tables, &dir, &vec![0; 4 * tables], 4 * tables + 1024) +} + +#[test] +fn a_woff2_collection_rebuilding_huge_directories_is_refused_before_decoding() { + // Each font's rebuilt directory takes 16 bytes per table it lists, the file about two: 600 + // fonts listing 2,000 tables each come to 19 MB of directories from a 2.4 MB file. + let all: Vec = (0..2_000).collect(); + let file = woff2_collection(2_000, &vec![all; 600]); + assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into()))); +} + +#[test] +fn a_woff2_collection_font_listing_a_table_twice_is_refused() { + let file = woff2_collection(3, &[vec![0, 1, 2], vec![0, 1, 1]]); + assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into()))); + // Fonts sharing tables are what collections are for: passed on to the decoder. + let file = woff2_collection(3, &[vec![0, 1, 2], vec![0, 1, 2]]); + assert_ne!(decode_font_file(&file), Err(UserFontError::BadWebFont(BAD_COLLECTION.into()))); +}