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.
This commit is contained in:
becauseno7
2026-11-01 14:44:26 +06:00
parent 98484975c4
commit 25a7d98ebc
2 changed files with 27 additions and 8 deletions
+14 -8
View File
@@ -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<File, Error> {
}
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<File, Error> {
// 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);
}
+13
View File
@@ -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}");
}
}