mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 14:38:21 -06:00
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.
This commit is contained in:
@@ -51,11 +51,12 @@ fn open_inner(bytes: &[u8]) -> Result<File, Error> {
|
||||
// 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<File, Error> {
|
||||
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"));
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user