From 4319df2e59abcbdaed7a7755b4a765235a2c24f2 Mon Sep 17 00:00:00 2001 From: becauseno7 <95076423+becauseno7@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:46:34 +0600 Subject: [PATCH 1/4] PDF import: the encryption pre-scan reads a bounded window after each trailer (#2142) The pre-scan lexed one object after every `trailer` marker over the rest of the file, so an unterminated string or dictionary after each of many markers made opening a crafted PDF quadratic. Each lex now gets a 4 KiB window; when it runs out, the file is refused if `/Encrypt` appears past the window (one search per file), so long trailers can't hide the key. --- crates/pdf/src/read.rs | 22 +++++++++++++--- crates/pdf/src/tests/reader.rs | 48 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/read.rs b/crates/pdf/src/read.rs index 953582fc8..927e82752 100644 --- a/crates/pdf/src/read.rs +++ b/crates/pdf/src/read.rs @@ -2,6 +2,7 @@ //! Only reachable objects are retained, references keep their generation, and native parser //! panics (including lazy object reads) discard the entire partially populated adapter. +use std::cell::LazyCell; use std::collections::{HashMap, HashSet, VecDeque}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::Arc; @@ -16,6 +17,14 @@ const MAX_OBJECTS: usize = 100_000; const MAX_VALUES: usize = 1_000_000; const MAX_DEPTH: usize = 64; const MAX_BYTES: usize = 512 * 1024 * 1024; +/// How far after each `trailer` marker the encryption pre-scan lexes. A classic trailer +/// dictionary is a few hundred bytes. If the lexer reaches the window's end, the dictionary +/// may start or continue past it, so the file is refused when `/Encrypt` appears anywhere past +/// the window. This fallback is conservative: it refuses on any `/Encrypt` text there, which +/// in practice only encrypted files contain. The pre-scan stays linear in file size, at most +/// `MAX_OBJECTS` windows (about 400 MB) and one search for `/Encrypt`, even when a marker is +/// followed by an unterminated string or dictionary the lexer would follow to the end. +const TRAILER_WINDOW: usize = 4 * 1024; fn limit(what: &str) -> Error { Error::Read(format!("PDF exceeds the {what} import limit")) @@ -41,6 +50,7 @@ fn open_inner(bytes: &[u8]) -> Result { // Preserve the app's refusal of encrypted PDFs, including empty-user-password files. // Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below // are read by the shared parser. This does not parse document objects a second time. + let last_encrypt = LazyCell::new(|| bytes.windows(8).rposition(|word| word == b"/Encrypt")); let mut markers = 0; for (offset, word) in bytes.windows(7).enumerate() { if word == b"trailer" { @@ -48,9 +58,15 @@ fn open_inner(bytes: &[u8]) -> Result { if markers > MAX_OBJECTS { return Err(limit("trailer count")); } - if let Some(Obj::Dict(dict)) = Lexer::new(bytes, offset + 7).next() - && dict.contains_key("Encrypt") - { + let start = offset + 7; + let end = bytes.len().min(start.saturating_add(TRAILER_WINDOW)); + let window = bytes.get(start..end).unwrap_or_default(); + let mut lexer = Lexer::new(window, 0); + let encrypt = matches!(lexer.next(), Some(Obj::Dict(dict)) if dict.contains_key("Encrypt")); + // Whatever it returned, a lexer at the window's end (one past it after an unterminated + // hex string) may have stopped before the dictionary started or partway through it. + let cut = lexer.pos >= window.len() && last_encrypt.is_some_and(|at| at.saturating_add(8) > end); + if encrypt || cut { return Err(Error::Encrypted); } } diff --git a/crates/pdf/src/tests/reader.rs b/crates/pdf/src/tests/reader.rs index 999a9900f..748ea0c87 100644 --- a/crates/pdf/src/tests/reader.rs +++ b/crates/pdf/src/tests/reader.rs @@ -74,3 +74,51 @@ fn duplicate_unreferenced_compressed_objects_do_not_block_repair() { assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object from an object stream: 10 0"))); assert!(matches!(parse(&document("/Missing 30 0 R", &objects)), Err(Error::Read(message)) if message.contains("ambiguous recovered object 30"))); } + +#[test] +fn many_trailer_markers_with_unterminated_objects_do_not_rescan_the_file() { + // The encryption pre-scan lexes after every `trailer` marker. Unbounded, each unterminated + // string or dictionary here ran to the end of the file: about 10^11 bytes lexed (minutes), + // against 2 × 10^8 within the window. The markers sit in an unreferenced stream, so the + // file still opens as the plain document. + let junk = "trailer(trailer<<".repeat(25_000) + &" ".repeat(2 << 20); + let stream = (6, 0, format!("<< /Length {} >>\nstream\n{junk}\nendstream", junk.len())); + let plain = parse(&document("", &[])).unwrap(); + assert_eq!(parse(&document("", &[stream])), Ok(plain)); +} + +#[test] +fn a_classic_trailer_with_an_encrypt_entry_is_refused() { + let plain = String::from_utf8(document("", &[])).unwrap(); + let encrypted = plain.replace("/Root 1 0 R >>", "/Root 1 0 R /Encrypt 9 0 R >>"); + assert_ne!(encrypted, plain); + // Object 9 does not exist: the trailer pre-scan refuses the file before the shared parser runs. + assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted)); +} + +#[test] +fn an_encrypt_entry_past_the_trailer_window_is_refused() { + // The filler pushes /Encrypt past the pre-scan's 4 KiB window. Object 9 does not exist, and the + // shared parser reads /Encrypt only as a dictionary, so it alone would open the file. + let plain = String::from_utf8(document("", &[])).unwrap(); + let filler = "x".repeat(8 * 1024); + let long = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /Filler ({filler}) >>")); + let encrypted = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /Filler ({filler}) /Encrypt 9 0 R >>")); + let doc = parse(plain.as_bytes()).unwrap(); + assert_eq!(parse(long.as_bytes()), Ok(doc)); + assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted)); +} + +#[test] +fn an_encrypt_entry_after_a_window_cut_before_the_trailer_dictionary_is_refused() { + // The 4 KiB pre-scan window ends in the whitespace before the trailer dictionary (the lexer + // returns nothing) or between the two `<` of its `<<` (the lexer reads a hex string). + let plain = String::from_utf8(document("", &[])).unwrap(); + let doc = parse(plain.as_bytes()).unwrap(); + for pad in [4 * 1024, 4 * 1024 - 2] { + let spaced = plain.replace("trailer\n<<", &format!("trailer\n{}<<", " ".repeat(pad))); + let encrypted = spaced.replace("/Root 1 0 R >>", "/Root 1 0 R /Encrypt 9 0 R >>"); + assert_eq!(parse(spaced.as_bytes()).as_ref(), Ok(&doc)); + assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted)); + } +} From cd6c56b78dac7328ffac873717815999dd4f93fc Mon Sep 17 00:00:00 2001 From: becauseno7 <95076423+becauseno7@users.noreply.github.com> Date: Sat, 31 Oct 2026 22:07:29 +0600 Subject: [PATCH 2/4] PDF import: the past-the-window /Encrypt fallback matches escaped names The fallback searched for the literal bytes `/Encrypt`, so a long trailer with the key written as `/Encr#79pt` (or any other #-escaped spelling) got past it. It now finds the last name token that decodes to `Encrypt`, with the lexer's own name decoding, and checks whether that token ends past the window. It is still one linear pass per file. Longer names that only start with `Encrypt` (`/Encrypted`) no longer count. --- crates/pdf/src/object.rs | 29 ++++++++++++++++++----------- crates/pdf/src/read.rs | 27 ++++++++++++++++++--------- crates/pdf/src/tests/reader.rs | 16 ++++++++++++++++ 3 files changed, 52 insertions(+), 20 deletions(-) diff --git a/crates/pdf/src/object.rs b/crates/pdf/src/object.rs index cdbb9733a..79a91dde8 100644 --- a/crates/pdf/src/object.rs +++ b/crates/pdf/src/object.rs @@ -275,19 +275,26 @@ fn hexv(c: u8) -> u8 { } } -fn decode_name(w: &[u8]) -> String { - let mut out = vec![]; +/// The bytes of a name token (after its `/`), with `#xx` escapes decoded. +pub(crate) fn name_bytes(w: &[u8]) -> impl Iterator { let mut i = 0; - while i < w.len() { - if w[i] == b'#' && w.get(i + 1).is_some_and(u8::is_ascii_hexdigit) && w.get(i + 2).is_some_and(u8::is_ascii_hexdigit) { - out.push((hexv(w[i + 1]) << 4) | hexv(w[i + 2])); - i += 3; - } else { - out.push(w[i]); - i += 1; + std::iter::from_fn(move || { + let c = *w.get(i)?; + match (c, w.get(i + 1), w.get(i + 2)) { + (b'#', Some(&hi), Some(&lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() => { + i += 3; + Some((hexv(hi) << 4) | hexv(lo)) + } + _ => { + i += 1; + Some(c) + } } - } - String::from_utf8_lossy(&out).into_owned() + }) +} + +fn decode_name(w: &[u8]) -> String { + String::from_utf8_lossy(&name_bytes(w).collect::>()).into_owned() } pub fn parse_num(w: &[u8]) -> Option { diff --git a/crates/pdf/src/read.rs b/crates/pdf/src/read.rs index 927e82752..799a00ff8 100644 --- a/crates/pdf/src/read.rs +++ b/crates/pdf/src/read.rs @@ -11,7 +11,7 @@ use craft_pdf::syntax::object::{Dict as PdfDict, MaybeRef, Name, ObjRef, Object, use craft_pdf::syntax::xref::XRef; use crate::Error; -use crate::object::{Dict, File, Lexer, Obj}; +use crate::object::{Dict, File, Lexer, Obj, is_delim, is_white, name_bytes}; const MAX_OBJECTS: usize = 100_000; const MAX_VALUES: usize = 1_000_000; @@ -19,11 +19,12 @@ const MAX_DEPTH: usize = 64; const MAX_BYTES: usize = 512 * 1024 * 1024; /// How far after each `trailer` marker the encryption pre-scan lexes. A classic trailer /// dictionary is a few hundred bytes. If the lexer reaches the window's end, the dictionary -/// may start or continue past it, so the file is refused when `/Encrypt` appears anywhere past -/// the window. This fallback is conservative: it refuses on any `/Encrypt` text there, which -/// in practice only encrypted files contain. The pre-scan stays linear in file size, at most -/// `MAX_OBJECTS` windows (about 400 MB) and one search for `/Encrypt`, even when a marker is -/// followed by an unterminated string or dictionary the lexer would follow to the end. +/// may start or continue past it, so the file is refused when a name token that decodes to +/// `Encrypt`, in any spelling (`/Encrypt`, `/Encr#79pt`), ends past the window. This fallback +/// is conservative: it refuses on any such name there, which in practice only encrypted files +/// contain. The pre-scan stays linear in file size, at most `MAX_OBJECTS` windows (about 400 MB) +/// and one search for the name, even when a marker is followed by an unterminated string or +/// dictionary the lexer would follow to the end. const TRAILER_WINDOW: usize = 4 * 1024; fn limit(what: &str) -> Error { @@ -50,7 +51,14 @@ fn open_inner(bytes: &[u8]) -> Result { // Preserve the app's refusal of encrypted PDFs, including empty-user-password files. // Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below // are read by the shared parser. This does not parse document objects a second time. - let last_encrypt = LazyCell::new(|| bytes.windows(8).rposition(|word| word == b"/Encrypt")); + // Where the last name token decoding to `Encrypt` (escaped or not) ends, searched at most once. + // Each token scan stops at the next `/`, so the search is linear. + let last_encrypt = LazyCell::new(|| { + bytes.iter().enumerate().rev().filter(|&(_, &c)| c == b'/').find_map(|(at, _)| { + let token = bytes.get(at + 1..).unwrap_or_default().split(|&c| is_white(c) || is_delim(c)).next().unwrap_or_default(); + name_bytes(token).eq(*b"Encrypt").then_some(at + 1 + token.len()) + }) + }); let mut markers = 0; for (offset, word) in bytes.windows(7).enumerate() { if word == b"trailer" { @@ -64,8 +72,9 @@ fn open_inner(bytes: &[u8]) -> Result { let mut lexer = Lexer::new(window, 0); let encrypt = matches!(lexer.next(), Some(Obj::Dict(dict)) if dict.contains_key("Encrypt")); // Whatever it returned, a lexer at the window's end (one past it after an unterminated - // hex string) may have stopped before the dictionary started or partway through it. - let cut = lexer.pos >= window.len() && last_encrypt.is_some_and(|at| at.saturating_add(8) > end); + // hex string) may have stopped before the dictionary started or partway through it, so + // an `Encrypt` name in any spelling that ends past the window refuses the file. + let cut = lexer.pos >= window.len() && last_encrypt.is_some_and(|name_end| name_end > end); if encrypt || cut { return Err(Error::Encrypted); } diff --git a/crates/pdf/src/tests/reader.rs b/crates/pdf/src/tests/reader.rs index 748ea0c87..de6c0a311 100644 --- a/crates/pdf/src/tests/reader.rs +++ b/crates/pdf/src/tests/reader.rs @@ -109,6 +109,22 @@ fn an_encrypt_entry_past_the_trailer_window_is_refused() { assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted)); } +#[test] +fn an_escaped_encrypt_entry_past_the_trailer_window_is_refused() { + // A name may write any byte as `#` and two hex digits, so these keys all read as /Encrypt and + // the past-the-window fallback refuses them. The decoys decode to other names and still open. + let plain = String::from_utf8(document("", &[])).unwrap(); + let doc = parse(plain.as_bytes()).unwrap(); + let filler = "x".repeat(8 * 1024); + let trailer = |key: &str| plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /Filler ({filler}) {key} 9 0 R >>")); + for key in ["/Encr#79pt", "/#45#6E#63rypt", "/E#6e#63rypt"] { + assert_eq!(parse(trailer(key).as_bytes()), Err(Error::Encrypted), "{key}"); + } + for decoy in ["/Encrypted", "/Encr#7Apt"] { + assert_eq!(parse(trailer(decoy).as_bytes()).as_ref(), Ok(&doc), "{decoy}"); + } +} + #[test] fn an_encrypt_entry_after_a_window_cut_before_the_trailer_dictionary_is_refused() { // The 4 KiB pre-scan window ends in the whitespace before the trailer dictionary (the lexer From 98484975c438798e36aede312a6282e4b26883c1 Mon Sep 17 00:00:00 2001 From: becauseno7 <95076423+becauseno7@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:31:59 +0600 Subject: [PATCH 3/4] PDF import: a name cut by the trailer window is read whole The pre-scan window could end inside a trailer key, so a decoy like `/Encrypted` cut right after `/Encrypt` was taken for the Encrypt key and an unencrypted file was refused. A window that ends mid-token now ends before that token instead, and the past-the-window fallback reads the name whole (a real `/Encrypt` cut as `/Encryp|t` is still refused). Tests for that boundary, for a trailer with a long /ID, and for `#` without two hex digits staying as written in a name. --- crates/pdf/src/read.rs | 11 +++++++-- crates/pdf/src/tests/reader.rs | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/pdf/src/read.rs b/crates/pdf/src/read.rs index 799a00ff8..39c594e7b 100644 --- a/crates/pdf/src/read.rs +++ b/crates/pdf/src/read.rs @@ -51,11 +51,12 @@ fn open_inner(bytes: &[u8]) -> Result { // Preserve the app's refusal of encrypted PDFs, including empty-user-password files. // Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below // are read by the shared parser. This does not parse document objects a second time. + let regular = |c: u8| !is_white(c) && !is_delim(c); // Where the last name token decoding to `Encrypt` (escaped or not) ends, searched at most once. // Each token scan stops at the next `/`, so the search is linear. let last_encrypt = LazyCell::new(|| { bytes.iter().enumerate().rev().filter(|&(_, &c)| c == b'/').find_map(|(at, _)| { - let token = bytes.get(at + 1..).unwrap_or_default().split(|&c| is_white(c) || is_delim(c)).next().unwrap_or_default(); + let token = bytes.get(at + 1..).unwrap_or_default().split(|&c| !regular(c)).next().unwrap_or_default(); name_bytes(token).eq(*b"Encrypt").then_some(at + 1 + token.len()) }) }); @@ -67,7 +68,13 @@ fn open_inner(bytes: &[u8]) -> Result { return Err(limit("trailer count")); } let start = offset + 7; - let end = bytes.len().min(start.saturating_add(TRAILER_WINDOW)); + let mut end = bytes.len().min(start.saturating_add(TRAILER_WINDOW)); + // A window ending inside a token (a regular byte on each side of the cut) would read its + // first part as all of it, such as `/Encrypt` of `/Encrypted`. It ends before that token + // instead, so the fallback below reads the token whole. + if bytes.get(end).is_some_and(|&c| regular(c)) { + end = end.saturating_sub(bytes.get(start..end).unwrap_or_default().iter().rev().take_while(|&&c| regular(c)).count()); + } let window = bytes.get(start..end).unwrap_or_default(); let mut lexer = Lexer::new(window, 0); let encrypt = matches!(lexer.next(), Some(Obj::Dict(dict)) if dict.contains_key("Encrypt")); diff --git a/crates/pdf/src/tests/reader.rs b/crates/pdf/src/tests/reader.rs index de6c0a311..adaa1152d 100644 --- a/crates/pdf/src/tests/reader.rs +++ b/crates/pdf/src/tests/reader.rs @@ -96,6 +96,19 @@ fn a_classic_trailer_with_an_encrypt_entry_is_refused() { assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted)); } +#[test] +fn a_trailer_with_a_long_id_fits_the_window() { + // Two 128-byte file identifiers leave the trailer dictionary under 600 bytes, well inside the + // 4 KiB pre-scan window, so /Encrypt after them is read there. + let plain = String::from_utf8(document("", &[])).unwrap(); + let id = format!("<{}>", "0123456789ABCDEF".repeat(16)); + let with_id = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /ID [{id} {id}] >>")); + let encrypted = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R /ID [{id} {id}] /Encrypt 9 0 R >>")); + let doc = parse(plain.as_bytes()).unwrap(); + assert_eq!(parse(with_id.as_bytes()), Ok(doc)); + assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted)); +} + #[test] fn an_encrypt_entry_past_the_trailer_window_is_refused() { // The filler pushes /Encrypt past the pre-scan's 4 KiB window. Object 9 does not exist, and the @@ -125,6 +138,16 @@ fn an_escaped_encrypt_entry_past_the_trailer_window_is_refused() { } } +#[test] +fn a_hash_without_two_hex_digits_stays_in_a_name() { + // `#` escapes a byte only before two hex digits. Otherwise it and what follows stay as written, + // so none of these reads as `Encrypt`. + let name = |token: &str| String::from_utf8(crate::object::name_bytes(token.as_bytes()).collect()).unwrap(); + assert_eq!(name("Encry#pt"), "Encry#pt"); + assert_eq!(name("Encrypt#"), "Encrypt#"); + assert_eq!(name("Encrypt#7"), "Encrypt#7"); +} + #[test] fn an_encrypt_entry_after_a_window_cut_before_the_trailer_dictionary_is_refused() { // The 4 KiB pre-scan window ends in the whitespace before the trailer dictionary (the lexer @@ -138,3 +161,22 @@ fn an_encrypt_entry_after_a_window_cut_before_the_trailer_dictionary_is_refused( assert_eq!(parse(encrypted.as_bytes()), Err(Error::Encrypted)); } } + +#[test] +fn a_key_cut_by_the_trailer_window_is_read_whole() { + // Spaces put the 4 KiB pre-scan window's end inside a trailer key, `inside` bytes into it. The + // `/Encrypt` of the decoy `/Encrypted` is not taken for the key, a real `/Encrypt` cut after + // `/Encryp` is still refused, and one ending exactly at the window's end is read there. + let plain = String::from_utf8(document("", &[])).unwrap(); + let doc = parse(plain.as_bytes()).unwrap(); + let window_end = plain.find("trailer").unwrap() + "trailer".len() + 4 * 1024; + let key_at = plain.find("/Root 1 0 R >>").unwrap() + "/Root 1 0 R ".len(); + let trailer = |key: &str, inside: usize| { + let file = plain.replace("/Root 1 0 R >>", &format!("/Root 1 0 R {}{key} 9 0 R >>", " ".repeat(window_end - inside - key_at))); + assert!(file[window_end - inside..].starts_with(key), "{key}"); + file + }; + assert_eq!(parse(trailer("/Encrypted", 8).as_bytes()).as_ref(), Ok(&doc)); + assert_eq!(parse(trailer("/Encrypt", 7).as_bytes()), Err(Error::Encrypted)); + assert_eq!(parse(trailer("/Encrypt", 8).as_bytes()), Err(Error::Encrypted)); +} From 25a7d98ebc0e9cc0f8fb49c75f222cba7cf8903e Mon Sep 17 00:00:00 2001 From: becauseno7 <95076423+becauseno7@users.noreply.github.com> Date: Sun, 1 Nov 2026 14:44:26 +0600 Subject: [PATCH 4/4] PDF import: a trailer string in a stream does not trigger the window fallback A `trailer` marker inside an unreferenced stream followed by `(` and more than 4 KiB of text made the lexer stop at the window's end, so a later `/Encrypt` name anywhere in the file refused a plain file. The unbounded scan read a string there and opened it. The past-the-window fallback now applies only when no object starts in the window or the first one opens a dictionary (`<<`, including one split by the window's end). Regression test with `trailer(` and `trailer[` in a stream and a later Encrypt name. --- crates/pdf/src/read.rs | 22 ++++++++++++++-------- crates/pdf/src/tests/reader.rs | 13 +++++++++++++ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/crates/pdf/src/read.rs b/crates/pdf/src/read.rs index 39c594e7b..b9c86d0ad 100644 --- a/crates/pdf/src/read.rs +++ b/crates/pdf/src/read.rs @@ -18,13 +18,13 @@ const MAX_VALUES: usize = 1_000_000; const MAX_DEPTH: usize = 64; const MAX_BYTES: usize = 512 * 1024 * 1024; /// How far after each `trailer` marker the encryption pre-scan lexes. A classic trailer -/// dictionary is a few hundred bytes. If the lexer reaches the window's end, the dictionary -/// may start or continue past it, so the file is refused when a name token that decodes to -/// `Encrypt`, in any spelling (`/Encrypt`, `/Encr#79pt`), ends past the window. This fallback -/// is conservative: it refuses on any such name there, which in practice only encrypted files -/// contain. The pre-scan stays linear in file size, at most `MAX_OBJECTS` windows (about 400 MB) -/// and one search for the name, even when a marker is followed by an unterminated string or -/// dictionary the lexer would follow to the end. +/// dictionary is a few hundred bytes. If the lexer reaches the window's end before any object +/// or inside a dictionary, the dictionary may start or continue past it, so the file is refused +/// when a name token that decodes to `Encrypt`, in any spelling (`/Encrypt`, `/Encr#79pt`), ends +/// past the window. This fallback is conservative: it refuses on any such name there, which in +/// practice only encrypted files contain. The pre-scan stays linear in file size, at most +/// `MAX_OBJECTS` windows (about 400 MB) and one search for the name, even when a marker is +/// followed by an unterminated string or dictionary the lexer would follow to the end. const TRAILER_WINDOW: usize = 4 * 1024; fn limit(what: &str) -> Error { @@ -69,6 +69,12 @@ fn open_inner(bytes: &[u8]) -> Result { } let start = offset + 7; let mut end = bytes.len().min(start.saturating_add(TRAILER_WINDOW)); + // If the first object after the marker starts in the window and does not open a dictionary + // (`<<`), such as a string running on through a stream, no trailer dictionary follows the + // marker, so the fallback below does not apply. + let mut first = Lexer::new(bytes.get(..end).unwrap_or_default(), start); + first.skip_ws(); + let maybe_dict = first.pos >= end || bytes.get(first.pos..).is_some_and(|rest| rest.starts_with(b"<<")); // A window ending inside a token (a regular byte on each side of the cut) would read its // first part as all of it, such as `/Encrypt` of `/Encrypted`. It ends before that token // instead, so the fallback below reads the token whole. @@ -81,7 +87,7 @@ fn open_inner(bytes: &[u8]) -> Result { // Whatever it returned, a lexer at the window's end (one past it after an unterminated // hex string) may have stopped before the dictionary started or partway through it, so // an `Encrypt` name in any spelling that ends past the window refuses the file. - let cut = lexer.pos >= window.len() && last_encrypt.is_some_and(|name_end| name_end > end); + let cut = maybe_dict && lexer.pos >= window.len() && last_encrypt.is_some_and(|name_end| name_end > end); if encrypt || cut { return Err(Error::Encrypted); } diff --git a/crates/pdf/src/tests/reader.rs b/crates/pdf/src/tests/reader.rs index adaa1152d..da5a0eab1 100644 --- a/crates/pdf/src/tests/reader.rs +++ b/crates/pdf/src/tests/reader.rs @@ -180,3 +180,16 @@ fn a_key_cut_by_the_trailer_window_is_read_whole() { assert_eq!(parse(trailer("/Encrypt", 7).as_bytes()), Err(Error::Encrypted)); assert_eq!(parse(trailer("/Encrypt", 8).as_bytes()), Err(Error::Encrypted)); } + +#[test] +fn a_trailer_string_in_a_stream_does_not_refuse_a_later_encrypt_name() { + // A `trailer` marker in an unreferenced stream opens a string or array that runs on past the + // 4 KiB pre-scan window to an `Encrypt` name. The unbounded lexer read that object, not a + // trailer dictionary, so the past-the-window fallback does not refuse the file either. + let plain = parse(&document("", &[])).unwrap(); + for (open, key) in [("(", "/Encrypt"), ("[", "/Encr#79pt")] { + let content = format!("trailer{open}{}{key} 9 0 R", "text ".repeat(1024)); + let stream = (6, 0, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())); + assert_eq!(parse(&document("", &[stream])).as_ref(), Ok(&plain), "{open}"); + } +}