mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 19:48:14 -06:00
* fix(create): cap decoded size of imported PNGs A PNG's declared dimensions sized an infallible allocation, so a few-byte file claiming huge dimensions aborted the process when the allocation failed. Cap the decoded size at the TIFF ceiling and reserve every input-sized buffer fallibly, returning CreateError::Image instead. (Security audit run-2: v3-024.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(xfa): bound total picture bytes kept during layout Each laid-out picture kept its own copy, so one picture in a repeating subform multiplied without bound and could exhaust memory on open. Add a per-layout aggregate cap (256 MiB) that stops with XfaError::TooLarge. (Security audit run-2: v3-103.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(xfdf): bound XML nesting and protect redaction marks on import Deeply nested XFDF/XML overflowed roxmltree's recursive parser and aborted the process; a non-recursive depth scan now rejects it before parsing. Imported comments could also replace a locked or redaction mark by /NM or add a hidden one; place() no longer replaces locked/Redact annotations and clears the hidden/invisible flags on imported redaction marks. (Security audit run-2: v3-105, v3-111.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(sign): classify post-signing changes by the signed revision's roles A signed object's modification was classified from labels in the attacker-written new version, so relabelling a signed page /Type /Sig (or giving it /ModDate), or relabelling a drawn stream /ObjStm or /XRef, let a forged page validate as Valid even under DocMDP P=1. Decide an object's role (page content, signature, metadata, file plumbing) from what it was in the signed revision, not its new dictionary. (Security audit run-2: v3-082, v3-083.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(redact): remove redacted content from the saved file, fail closed otherwise Apply Redactions and Remove Hidden Information left the originals behind: dropped or cleaned image/form XObjects kept their resource names (so the full rewrite saved the originals), an XFA form kept the redacted value in its datasets packet, and vertical-writing text under a mark was never hit by the horizontal removal geometry yet reported success. Prune replaced XObject names the page no longer draws, drop /XFA and /NeedsRendering when redacting an XFA form, and fail closed (RedactError::VerticalText) rather than silently leave vertical text. (Security audit run-2: v3-064, v3-067, v3-062.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ui-egui): trust the displayed signer, not the document's embedded root The Signatures panel's one-click 'Add to trusted certificates' trusted the top of the chain the document supplied, installing the author's self-signed root as an anchor instead of the signer shown. Trust the displayed signer certificate. (Security audit run-2: v3-098.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Use main's decoded-size ceiling for PNGs too; keep the depth tests on main's checker --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
88 lines
4.1 KiB
Rust
88 lines
4.1 KiB
Rust
//! The Signatures panel's one-click "Add to trusted certificates" must store the signer shown in
|
|
//! the row, not the chain top the document embedded in the CMS (audit v3-098). Signing with a
|
|
//! certificate whose CMS carries its issuer chain gives a signature whose `chain.last()` (the
|
|
//! self-signed root) differs from its `certificate` (the signer); the click must trust the signer.
|
|
|
|
use egui_kittest::Harness;
|
|
use egui_kittest::kittest::Queryable;
|
|
use pdfcraft_engine::SignatureStatus;
|
|
use pdfcraft_ui_egui::{PdfCraftApp, SignStep};
|
|
|
|
const FIXTURE: &[u8] = b"%PDF-1.7
|
|
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
|
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
|
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
|
|
4 0 obj << /Length 45 >> stream
|
|
BT /F1 14 Tf 20 150 Td (Please sign below) Tj ET
|
|
endstream endobj
|
|
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
|
|
trailer << /Root 1 0 R >>
|
|
%%EOF";
|
|
|
|
fn dir() -> std::path::PathBuf {
|
|
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
|
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let d = std::env::temp_dir().join(format!("pdfcraft-trust-signer-{}-{n}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
d
|
|
}
|
|
|
|
fn harness(dir: std::path::PathBuf) -> Harness<'static, PdfCraftApp> {
|
|
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
|
let mut app = PdfCraftApp::new();
|
|
app.set_option("language", "en").unwrap();
|
|
app.open_bytes("contract.pdf", None, FIXTURE.to_vec()).expect("opens");
|
|
app.set_option("left", "closed").unwrap();
|
|
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
|
|
app.save_override = Some(dir.join("contract_signed.pdf").to_string_lossy().into_owned());
|
|
app
|
|
});
|
|
h.run_steps(6);
|
|
h
|
|
}
|
|
|
|
#[test]
|
|
fn one_click_trust_stores_the_displayed_signer_not_the_embedded_root() {
|
|
let dir = dir();
|
|
let mut h = harness(dir);
|
|
// Sign with chain.p12, whose CMS embeds the signer's issuer chain (signer + self-signed root).
|
|
let p12 = concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/chain.p12");
|
|
let id = pdfcraft_engine::sign::pkcs12::open(&std::fs::read(p12).unwrap(), "test").unwrap();
|
|
let entry = h.state_mut().add_digital_id(p12, &id.certificate);
|
|
h.state_mut().start_signing(0, None, None, None);
|
|
{
|
|
let d = h.state_mut().sign_draft.as_mut().unwrap();
|
|
d.selected = Some(entry);
|
|
d.step = SignStep::SignAs;
|
|
d.password = "test".into();
|
|
}
|
|
h.run_steps(2);
|
|
h.get_by_label("Sign").click();
|
|
h.run_steps(6);
|
|
|
|
// The signature validates as Unknown and carries a two-certificate chain whose top (the root)
|
|
// is a different certificate from the displayed signer.
|
|
let (signer, root) = {
|
|
let s = h.state();
|
|
let sig = s.session.get(s.views[0].id).unwrap().signatures.iter().find(|x| x.signed).cloned().expect("a signed field");
|
|
assert_eq!(sig.status, SignatureStatus::Unknown, "untrusted before the click");
|
|
assert_eq!(sig.chain.len(), 2, "the CMS embeds the signer and its root: {:?}", sig.chain.len());
|
|
let signer = sig.certificate.clone().expect("a signer certificate");
|
|
let root = sig.chain.last().cloned().expect("a chain top");
|
|
assert_ne!(signer.raw, root.raw, "the embedded chain top must differ from the signer for this test to be meaningful");
|
|
(signer, root)
|
|
};
|
|
|
|
// Expand the signature row and use the one-click trust button.
|
|
h.get_by_label_contains("Signed by").click();
|
|
h.run_steps(2);
|
|
h.get_by_label("Add to trusted certificates").click();
|
|
h.run_steps(3);
|
|
|
|
// The trust store holds exactly the displayed signer, never the document-supplied root.
|
|
let trusted = h.state().session.trusted_certificates().to_vec();
|
|
assert!(trusted.iter().any(|c| c.raw == signer.raw), "the displayed signer should be trusted");
|
|
assert!(!trusted.iter().any(|c| c.raw == root.raw), "the embedded root must not be trusted by the one-click action");
|
|
}
|