fix(redact): evaluate OCMD visibility during sanitization

Fixes #3499 by evaluating OCMD policies and bounded visibility expressions against the default optional-content configuration before removing hidden groups. Unknown or malformed visibility data is handled conservatively.
This commit is contained in:
skylar taylor-barrick
2026-10-19 12:24:55 -06:00
parent ec0a965074
commit dd20be5f91
2 changed files with 260 additions and 2 deletions
+109 -2
View File
@@ -124,7 +124,8 @@ impl<'a> Scope<'a> {
{
return true;
}
// A membership dictionary (default policy AnyOn): hidden when all its groups are.
// Evaluate the policy against the same default configuration used to select hidden
// groups. A membership dictionary's default policy is AnyOn.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if d.name(b"Type") != Some(b"OCMD") {
@@ -135,7 +136,113 @@ impl<'a> Scope<'a> {
Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(),
None => Vec::new(),
};
!groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g))
let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g));
if let Some(expression) = d.get(b"VE") {
let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096);
return !visible
.unwrap_or_else(|| !fail_closed() && !self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096));
}
if groups.is_empty() {
return false;
}
let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g));
let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g));
let policy = match d.get(b"P") {
None => b"AnyOn".as_slice(),
Some(Object::Name(name)) => name.as_slice(),
Some(_) => return fail_closed(),
};
match policy {
b"AnyOn" => !any_on,
b"AllOn" => !all_on,
b"AnyOff" => all_on,
b"AllOff" => any_on,
// An invalid policy must not make a candidate hidden block survive sanitization.
_ => fail_closed(),
}
}
/// Evaluate an optional-content visibility expression (`/VE`) against the default config.
/// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed.
fn visibility_expression(
&self,
doc: &Document,
expression: &Object,
depth: usize,
path: &mut HashSet<ObjRef>,
budget: &mut usize,
) -> Option<bool> {
if depth > 64 || *budget == 0 {
return None;
}
*budget -= 1;
let reference = expression.as_ref();
if let Some(r) = reference
&& !path.insert(r)
{
return None;
}
let resolved = doc.resolve(expression);
let result = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
Object::Array(items) if (2..=4097).contains(&items.len()) => {
let operator = match items.first() {
Some(Object::Name(n)) => n.as_slice(),
_ => return None,
};
match operator {
b"And" if items.len() >= 3 => {
let mut visible = true;
for item in items.iter().skip(1) {
visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?;
}
Some(visible)
}
b"Or" if items.len() >= 3 => {
let mut visible = false;
for item in items.iter().skip(1) {
visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?;
}
Some(visible)
}
b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?),
_ => None,
}
}
_ => None,
};
if let Some(r) = reference {
path.remove(&r);
}
result
}
/// Conservatively find hidden groups in an expression that could not be evaluated. Unknown
/// syntax, cycles, and exhausted budgets count as a possible hidden reference.
fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet<ObjRef>, budget: &mut usize) -> bool {
if depth > 64 || *budget == 0 {
return true;
}
*budget -= 1;
if let Some(r) = expression.as_ref() {
if self.hidden_layers.contains(&r) {
return true;
}
if !path.insert(r) {
return true;
}
}
let resolved = doc.resolve(expression);
let found = match &*resolved {
Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)),
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(_) => true,
_ => false,
};
if let Some(r) = expression.as_ref() {
path.remove(&r);
}
found
}
fn hits(&self, b: [f64; 4]) -> bool {
+151
View File
@@ -492,6 +492,157 @@ fn a_hidden_layers_image_does_not_survive_through_inherited_resources() {
assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save");
}
#[test]
fn hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
],
);
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(pdfcraft_cos::Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(
b"D".to_vec(),
Object::Dict(pdfcraft_cos::Dict::from_iter([
(b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
])),
),
])),
);
})
.unwrap();
let before = pdfcraft_edit::text_lines(&doc, 0).unwrap();
assert!(before.iter().any(|line| line.text.contains("PUBLIC")));
assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap();
assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}");
assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}");
assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET"));
}
#[test]
fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"/Fm Do",
"/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>",
b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC",
),
],
);
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
}
#[test]
fn hidden_ocmd_policy_matches_the_default_configuration() {
use crate::sanitize::{Hidden, remove_hidden};
let cases = [
(None, false, None, false),
(Some("AnyOn"), false, None, false),
(Some("AllOn"), false, None, true),
(Some("AnyOff"), false, None, false),
(Some("AllOff"), false, None, true),
(Some("UnknownPolicy"), false, None, true),
(None, true, None, true),
(None, false, Some("and"), true),
(Some("AllOn"), false, Some("not_off"), false),
(None, false, Some("malformed_not"), true),
(None, false, Some("malformed_without_ocgs"), true),
];
for (policy, malformed_policy, visibility, should_remove) in cases {
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
],
);
let mut membership = Dict::new();
membership.set(b"Type".to_vec(), Object::name("OCMD"));
if visibility != Some("malformed_without_ocgs") {
membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))]));
}
if let Some(policy) = policy {
membership.set(b"P".to_vec(), Object::name(policy));
} else if malformed_policy {
membership.set(b"P".to_vec(), Object::Int(17));
}
match visibility {
Some("and") => membership
.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])),
Some("malformed_not") => membership
.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
Some("malformed_without_ocgs") => {
membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))]))
}
_ => {}
}
doc.set(ObjRef::new(9, 0), Object::Dict(membership));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(
b"D".to_vec(),
Object::Dict(Dict::from_iter([
(b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
])),
),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"));
assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}");
}
}
#[test]
fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
// A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA