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] 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}"); + } +}