fix(redact): read content strictly before sanitizing or redacting it

Page and Form content was decoded tolerantly, keeping what came before a
corrupt spot and dropping the rest without saying so. A page whose content
broke at its first bytes therefore read as empty: the hidden-text scan
counted nothing, the removal was skipped and the original stream, which
other viewers still draw, stayed in the file. Content is now decoded
strictly; an unreadable page counts as something to remove, so the removal
runs and refuses the page, and a Form read only in part is treated like an
undecodable one.
This commit is contained in:
aiongg
2026-10-22 13:28:40 -04:00
committed by Dylan M. Taylor
parent f1c56ef865
commit ee010fbbe5
4 changed files with 28 additions and 2 deletions
+2 -1
View File
@@ -1201,7 +1201,8 @@ fn xobject(
out.doomed.push(r);
return Some(None);
}
let data = match s.decoded() {
// Strict, like page content: a Form read only in part counts as undecodable.
let data = match s.decoded_strict() {
Ok(d) => d,
Err(_) if scope.mode == Mode::Apply => {
scope.report.forms_removed += 1;
+3 -1
View File
@@ -231,7 +231,9 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<
let mut data = Vec::new();
for o in &list {
match &*doc.resolve(o) {
Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?),
// Strict: a tolerant decode keeps what precedes corruption and drops the rest, which
// still draws in viewers that read further; anything not read can't be cleaned.
Object::Stream(s) => data.push(s.decoded_strict().map_err(|_| RedactError::Unreadable(index + 1))?),
Object::Null => data.push(Vec::new()),
_ => return Err(RedactError::Unreadable(index + 1)),
}
+3
View File
@@ -205,6 +205,9 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
if write {
return Err(RedactError::Unreadable(pi + 1));
}
// Content that can't be read may hide anything: count it so the removal runs and
// refuses the page, instead of a zero count skipping it.
total += 1;
continue;
};
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
+20
View File
@@ -2494,3 +2494,23 @@ fn a_name_tree_whose_nodes_share_kids_counts_each_entry_once() {
let counts: std::collections::HashMap<Hidden, usize> = scan(&doc).into_iter().collect();
assert_eq!(counts[&Hidden::Attachments], 1, "one entry, reached through 2^{levels} paths");
}
/// Page content that stops decoding at its start (`z` inside an ASCII85 group) while the rest of
/// the stream draws hidden text. A tolerant decode reads nothing, counts nothing and keeps the
/// stream; sanitizing must refuse instead.
#[test]
fn hidden_text_in_content_that_stops_decoding_is_refused() {
use crate::sanitize::{Hidden, remove_hidden, sanitize};
let encoded: &[u8] = br"!!z6<#'\7PQ#?0Ha>,+>Y->EZd(j+>PW)+B2ko-rWQ%;FOP6+B3(u78s~>";
let fresh = || {
let mut doc = one_page(b"", "", vec![stream("/Filter /ASCII85Decode", encoded)]);
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0)))).unwrap();
doc
};
let mut doc = fresh();
let out = remove_hidden(&mut doc, &[Hidden::HiddenText]);
assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}");
let mut doc = fresh();
let out = sanitize(&mut doc);
assert!(matches!(out, Err(RedactError::Unreadable(1))), "{out:?}");
}