mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 03:27:21 -06:00
Fuzzed inputs drove one check-one process to 2-6 GB (the 16 GB CI runner running two of them was killed every night). Three causes, each with a test: - lopdf (bootstrap inspector) sized PNG predictor rows from /Columns before reading data: /Columns 4294967295 allocated two 4 GiB rows. Vendored lopdf 0.45.0 refuses rows longer than the data (and uses saturating row widths); its load-time stream decoding also gets a 256 MiB limit (it had none). - hayro sized a tiling pattern's cell pixmap from /XStep x scale, bounded only by u16: a step far beyond /BBox allocated 2 GB. The vendored patch keeps the cell within 3000 px a side, lowering the resolution instead. - cos decoded cross-reference streams up to the general 1 GiB limit; they now stop at what 8,388,607 objects' rows can hold (~200 MB at most), and the section is reconstructed (logged) instead. On the nightly's seed (41) the worst captured input fell from 6.2 GB to 6 MB, and a 300 s fuzz run had no process above 300 MB (before: many at 2-5 GB). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
186 lines
6.1 KiB
Rust
186 lines
6.1 KiB
Rust
//! Saving an encrypted document with object streams enabled.
|
|
//!
|
|
//! Object streams are assembled while serializing, long after `Document::encrypt`
|
|
//! has encrypted every object and dropped the file encryption key, so the writer
|
|
//! has nothing left to encrypt one with.
|
|
//! See https://github.com/J-F-Liu/lopdf/issues/479.
|
|
|
|
#![cfg(not(feature = "async"))]
|
|
|
|
use lopdf::{
|
|
Document, EncryptionState, EncryptionVersion, LoadOptions, Object, Permissions, SaveOptions, Stream, StringFormat,
|
|
dictionary,
|
|
};
|
|
|
|
/// Build a one page document carrying a string we can check after a round trip.
|
|
fn sample_document() -> Document {
|
|
let mut doc = Document::with_version("1.7");
|
|
|
|
let pages_id = doc.new_object_id();
|
|
let content_id = doc.add_object(Stream::new(dictionary! {}, b"BT ET".to_vec()));
|
|
let page_id = doc.add_object(dictionary! {
|
|
"Type" => "Page",
|
|
"Parent" => Object::Reference(pages_id),
|
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
|
"Contents" => Object::Reference(content_id),
|
|
});
|
|
doc.objects.insert(
|
|
pages_id,
|
|
Object::Dictionary(dictionary! {
|
|
"Type" => "Pages",
|
|
"Kids" => vec![Object::Reference(page_id)],
|
|
"Count" => 1,
|
|
}),
|
|
);
|
|
let catalog_id = doc.add_object(dictionary! {
|
|
"Type" => "Catalog",
|
|
"Pages" => Object::Reference(pages_id),
|
|
});
|
|
let info_id = doc.add_object(dictionary! {
|
|
"Title" => Object::String(b"lopdf 479".to_vec(), StringFormat::Literal),
|
|
});
|
|
|
|
doc.trailer.set("Root", Object::Reference(catalog_id));
|
|
doc.trailer.set("Info", Object::Reference(info_id));
|
|
let id = vec![0x42u8; 16];
|
|
doc.trailer.set(
|
|
"ID",
|
|
Object::Array(vec![
|
|
Object::String(id.clone(), StringFormat::Literal),
|
|
Object::String(id, StringFormat::Literal),
|
|
]),
|
|
);
|
|
|
|
doc
|
|
}
|
|
|
|
/// AES-128, the setup from the issue report.
|
|
fn encrypt_aes128(doc: &mut Document) {
|
|
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
let crypt_filter: Arc<dyn lopdf::encryption::crypt_filters::CryptFilter> =
|
|
Arc::new(lopdf::encryption::crypt_filters::Aes128CryptFilter);
|
|
let version = EncryptionVersion::V4 {
|
|
document: doc,
|
|
encrypt_metadata: true,
|
|
crypt_filters: BTreeMap::from([(b"StdCF".to_vec(), crypt_filter)]),
|
|
stream_filter: b"StdCF".to_vec(),
|
|
string_filter: b"StdCF".to_vec(),
|
|
owner_password: "owner",
|
|
user_password: "",
|
|
permissions: Permissions::PRINTABLE,
|
|
};
|
|
let state = EncryptionState::try_from(version).unwrap();
|
|
doc.encrypt(&state).unwrap();
|
|
}
|
|
|
|
/// RC4-128, to show the problem is not specific to the AES crypt filters.
|
|
fn encrypt_rc4_128(doc: &mut Document) {
|
|
let version = EncryptionVersion::V2 {
|
|
document: doc,
|
|
owner_password: "owner",
|
|
user_password: "",
|
|
key_length: 128,
|
|
permissions: Permissions::PRINTABLE,
|
|
};
|
|
let state = EncryptionState::try_from(version).unwrap();
|
|
doc.encrypt(&state).unwrap();
|
|
}
|
|
|
|
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
|
haystack.windows(needle.len()).any(|window| window == needle)
|
|
}
|
|
|
|
/// Assert the saved bytes are a readable encrypted PDF.
|
|
fn assert_round_trips(buffer: &[u8], label: &str) {
|
|
let doc = Document::load_mem_with_options(buffer, LoadOptions::with_password("owner"))
|
|
.unwrap_or_else(|err| panic!("{label}: reloading the saved document failed: {err:?}"));
|
|
|
|
assert!(doc.was_encrypted(), "{label}: document should have been encrypted");
|
|
|
|
let pages = doc.get_pages();
|
|
assert_eq!(pages.len(), 1, "{label}: expected exactly one page");
|
|
|
|
// Before the fix this came back as the raw ciphertext of the title: the object
|
|
// holding it was packed into an object stream, so the reader decrypts the stream
|
|
// as a whole and correctly leaves the strings inside it alone, but `encrypt` had
|
|
// already encrypted them individually.
|
|
let title = doc
|
|
.trailer
|
|
.get(b"Info")
|
|
.and_then(Object::as_reference)
|
|
.and_then(|id| doc.get_dictionary(id))
|
|
.and_then(|dict| dict.get(b"Title"))
|
|
.and_then(Object::as_str)
|
|
.unwrap_or_else(|err| panic!("{label}: /Title is unreadable: {err:?}"));
|
|
assert_eq!(title, b"lopdf 479", "{label}: /Title did not survive the round trip");
|
|
|
|
let page_id = *pages.get(&1).unwrap();
|
|
let content = doc.get_page_content(page_id);
|
|
assert_eq!(
|
|
content.trim_ascii_end(),
|
|
b"BT ET",
|
|
"{label}: page content did not survive the round trip"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn save_modern_keeps_encrypted_documents_readable() {
|
|
let mut doc = sample_document();
|
|
encrypt_aes128(&mut doc);
|
|
|
|
let mut buffer = Vec::new();
|
|
doc.save_modern(&mut buffer).unwrap();
|
|
|
|
assert_round_trips(&buffer, "save_modern");
|
|
}
|
|
|
|
#[test]
|
|
fn save_with_object_streams_keeps_encrypted_rc4_documents_readable() {
|
|
let mut doc = sample_document();
|
|
encrypt_rc4_128(&mut doc);
|
|
|
|
let options = SaveOptions::builder()
|
|
.use_object_streams(true)
|
|
.use_xref_streams(false)
|
|
.build();
|
|
let mut buffer = Vec::new();
|
|
doc.save_with_options(&mut buffer, options).unwrap();
|
|
|
|
assert_round_trips(&buffer, "save_with_options");
|
|
}
|
|
|
|
#[test]
|
|
fn save_modern_writes_no_object_stream_when_encrypted() {
|
|
let mut doc = sample_document();
|
|
encrypt_aes128(&mut doc);
|
|
|
|
let mut buffer = Vec::new();
|
|
doc.save_modern(&mut buffer).unwrap();
|
|
|
|
assert!(
|
|
!contains(&buffer, b"/ObjStm"),
|
|
"an encrypted document must not be packed into an object stream the writer cannot encrypt"
|
|
);
|
|
// The fallback drops object streams only. Cross-reference streams are never
|
|
// encrypted, so `save_modern` still gets the modern cross-reference section.
|
|
assert!(
|
|
contains(&buffer, b"/XRef"),
|
|
"the requested cross-reference stream should still be written"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn save_modern_still_uses_object_streams_when_not_encrypted() {
|
|
let mut doc = sample_document();
|
|
|
|
let mut buffer = Vec::new();
|
|
doc.save_modern(&mut buffer).unwrap();
|
|
|
|
assert!(
|
|
contains(&buffer, b"/ObjStm"),
|
|
"an unencrypted document should still be packed into object streams"
|
|
);
|
|
}
|