Skip to main content

otf_pdf_doc/
sanitize.rs

1//! Sanitizing: removing active and hidden content (JavaScript, risky
2//! actions, attachments, XFA, multimedia, metadata).
3//!
4//! Every object is visited once and cleaned in the overlay. The removed
5//! objects become unreachable, so a **full** save drops them from the file;
6//! an incremental save would leave the old bytes in place and must not be
7//! used after sanitizing (DECISIONS.md D26).
8
9use otf_pdf_syntax::{Dict, ObjId, Object, Result};
10
11use crate::document::Document;
12use crate::store::Store;
13
14/// What [`Document::sanitize`] removes. [`SanitizeOptions::default`] removes
15/// everything that can run or hide data, and keeps links and metadata.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct SanitizeOptions {
18    /// JavaScript: document-level scripts, `/OpenAction` and `/AA` triggers,
19    /// and every `/JavaScript` action.
20    pub javascript: bool,
21    /// Actions that reach outside the document: `/Launch`, `/SubmitForm`,
22    /// `/ImportData`, `/GoToE`, `/Rendition`, `/RichMediaExecute`.
23    pub external_actions: bool,
24    /// Link actions to other files and the web: `/URI` and `/GoToR`.
25    pub links: bool,
26    /// Embedded files: the `/EmbeddedFiles` tree, file attachment
27    /// annotations, associated files (`/AF`) and portfolios.
28    pub attachments: bool,
29    /// XFA forms, which carry their own scripting.
30    pub xfa: bool,
31    /// Multimedia annotations: `/RichMedia`, `/Movie`, `/Sound`, `/Screen`,
32    /// `/3D`.
33    pub multimedia: bool,
34    /// Metadata: the Info dictionary, XMP streams and `/PieceInfo`.
35    pub metadata: bool,
36}
37
38impl Default for SanitizeOptions {
39    fn default() -> Self {
40        Self {
41            javascript: true,
42            external_actions: true,
43            links: false,
44            attachments: true,
45            xfa: true,
46            multimedia: true,
47            metadata: false,
48        }
49    }
50}
51
52impl SanitizeOptions {
53    /// Every category, including links and metadata.
54    pub const ALL: SanitizeOptions = SanitizeOptions {
55        javascript: true,
56        external_actions: true,
57        links: true,
58        attachments: true,
59        xfa: true,
60        multimedia: true,
61        metadata: true,
62    };
63}
64
65/// How many items of each kind were removed.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub struct SanitizeReport {
68    /// Scripts and script triggers.
69    pub javascript: usize,
70    /// External actions.
71    pub external_actions: usize,
72    /// Link actions.
73    pub links: usize,
74    /// Attachments, attachment annotations and associated-file entries.
75    pub attachments: usize,
76    /// XFA forms.
77    pub xfa: usize,
78    /// Multimedia annotations.
79    pub multimedia: usize,
80    /// Info dictionaries, XMP streams and `/PieceInfo` entries.
81    pub metadata: usize,
82}
83
84impl SanitizeReport {
85    /// Whether nothing was removed.
86    pub fn is_empty(&self) -> bool {
87        *self == Self::default()
88    }
89}
90
91struct Cleaner<'a> {
92    store: &'a Store,
93    options: SanitizeOptions,
94    report: SanitizeReport,
95}
96
97impl Cleaner<'_> {
98    /// Whether the action (or any action in its `/Next` chain) is removed;
99    /// counts it.
100    fn removes_action(&mut self, action: &Object, depth: u32) -> bool {
101        if depth > 32 {
102            return false;
103        }
104        let Some(dict) = self.store.resolve_dict(action) else {
105            return false;
106        };
107        let o = self.options;
108        let removed = match dict.get_name(b"S") {
109            Some(b"JavaScript") if o.javascript => {
110                self.report.javascript += 1;
111                true
112            }
113            Some(
114                b"Launch" | b"SubmitForm" | b"ImportData" | b"GoToE" | b"Rendition"
115                | b"RichMediaExecute",
116            ) if o.external_actions => {
117                self.report.external_actions += 1;
118                true
119            }
120            Some(b"URI" | b"GoToR") if o.links => {
121                self.report.links += 1;
122                true
123            }
124            _ => false,
125        };
126        if removed {
127            return true;
128        }
129        // A safe action whose /Next runs a removed one is removed whole.
130        match dict.get(b"Next").map(|n| self.store.resolve(n)) {
131            Some(Object::Array(items)) => items.iter().any(|n| self.removes_action(n, depth + 1)),
132            Some(next @ Object::Dict(_)) => self.removes_action(&next, depth + 1),
133            _ => false,
134        }
135    }
136
137    fn removes_annotation(&mut self, annot: &Object) -> bool {
138        let Some(dict) = self.store.resolve_dict(annot) else {
139            return false;
140        };
141        match dict.get_name(b"Subtype") {
142            Some(b"FileAttachment") if self.options.attachments => {
143                self.report.attachments += 1;
144                true
145            }
146            Some(b"RichMedia" | b"Movie" | b"Sound" | b"Screen" | b"3D")
147                if self.options.multimedia =>
148            {
149                self.report.multimedia += 1;
150                true
151            }
152            _ => false,
153        }
154    }
155
156    /// Cleans `dict` in place (recursing into direct sub-objects); returns
157    /// whether it changed.
158    fn clean_dict(&mut self, dict: &mut Dict, depth: u32) -> bool {
159        if depth > 64 {
160            return false;
161        }
162        let o = self.options;
163        let mut changed = false;
164        let drop = |dict: &mut Dict, key: &[u8], counter: &mut usize| {
165            if dict.remove(key).is_some() {
166                *counter += 1;
167                true
168            } else {
169                false
170            }
171        };
172
173        for key in [&b"A"[..], b"OpenAction"] {
174            if let Some(action) = dict.get(key).cloned()
175                && self.removes_action(&action, 0)
176            {
177                dict.remove(key);
178                changed = true;
179            }
180        }
181        if let Some(aa) = dict.get(b"AA").cloned()
182            && let Some(mut triggers) = self.store.resolve_dict(&aa)
183        {
184            let keys: Vec<_> = triggers.iter().map(|(k, _)| k.clone()).collect();
185            for k in keys {
186                let action = triggers.get(k.as_bytes()).cloned().unwrap_or_default();
187                if self.removes_action(&action, 0) {
188                    triggers.remove(k.as_bytes());
189                    changed = true;
190                }
191            }
192            if triggers.is_empty() {
193                dict.remove(b"AA");
194            } else if changed {
195                // Written inline, also when /AA was indirect, so the
196                // filtered triggers replace the original ones.
197                dict.insert("AA", triggers);
198            }
199        }
200        if o.attachments {
201            changed |= drop(dict, b"AF", &mut self.report.attachments);
202            if dict.is_type(b"Catalog") {
203                changed |= drop(dict, b"Collection", &mut self.report.attachments);
204            }
205        }
206        if o.xfa {
207            changed |= drop(dict, b"XFA", &mut self.report.xfa);
208            if dict.remove(b"NeedsRendering").is_some() {
209                changed = true;
210            }
211        }
212        if o.metadata {
213            changed |= drop(dict, b"Metadata", &mut self.report.metadata);
214            changed |= drop(dict, b"PieceInfo", &mut self.report.metadata);
215        }
216        if let Some(Object::Array(annots)) = dict.get(b"Annots").cloned() {
217            let kept: Vec<Object> = annots
218                .iter()
219                .filter(|a| !self.removes_annotation(a))
220                .cloned()
221                .collect();
222            if kept.len() != annots.len() {
223                dict.insert("Annots", kept);
224                changed = true;
225            }
226        }
227        // Direct sub-dictionaries (an inline /AA, an annotation's /MK...).
228        let keys: Vec<_> = dict.iter().map(|(k, _)| k.clone()).collect();
229        for k in keys {
230            if let Some(mut v) = dict.remove(k.as_bytes()) {
231                changed |= self.clean_object(&mut v, depth + 1);
232                dict.insert(k, v);
233            }
234        }
235        changed
236    }
237
238    fn clean_object(&mut self, object: &mut Object, depth: u32) -> bool {
239        match object {
240            Object::Dict(d) => self.clean_dict(d, depth),
241            Object::Stream(s) => self.clean_dict(&mut s.dict, depth),
242            Object::Array(items) => {
243                let mut changed = false;
244                for i in items {
245                    changed |= self.clean_object(i, depth + 1);
246                }
247                changed
248            }
249            _ => false,
250        }
251    }
252}
253
254impl Document {
255    /// Removes the content selected by `options` and reports what went.
256    /// Save in full afterwards: only a full save drops the removed objects
257    /// from the file.
258    pub fn sanitize(&mut self, options: &SanitizeOptions) -> Result<SanitizeReport> {
259        let ids: Vec<ObjId> = self.store().object_ids();
260        let mut changes = Vec::new();
261        let mut cleaner = Cleaner {
262            store: self.store(),
263            options: *options,
264            report: SanitizeReport::default(),
265        };
266        for id in ids {
267            cleaner.store.limits().charge(1)?;
268            let Ok(obj) = cleaner.store.get(id) else {
269                continue;
270            };
271            // An action object reached only through removed keys simply
272            // becomes unreachable; standalone scripts are blanked too.
273            let mut copy = (*obj).clone();
274            let changed = cleaner.clean_object(&mut copy, 0);
275            if changed {
276                changes.push((id, copy));
277            }
278        }
279        let mut report = cleaner.report;
280        for (id, obj) in changes {
281            self.store_mut().set(id, obj);
282        }
283        // The document-level name trees: scripts and embedded files.
284        let mut catalog = self.catalog();
285        if let Some(names_obj) = catalog.get(b"Names").cloned()
286            && let Some(mut names) = self.store().resolve_dict(&names_obj)
287        {
288            let mut changed = false;
289            if options.javascript && names.remove(b"JavaScript").is_some() {
290                report.javascript += 1;
291                changed = true;
292            }
293            if options.attachments && names.remove(b"EmbeddedFiles").is_some() {
294                report.attachments += 1;
295                changed = true;
296            }
297            if changed {
298                match names_obj {
299                    Object::Ref(id) => self.store_mut().set(id, Object::Dict(names)),
300                    _ => {
301                        catalog.insert("Names", names);
302                        if let Some(id) = self.catalog_id() {
303                            self.store_mut().set(id, Object::Dict(catalog));
304                        }
305                    }
306                }
307            }
308        }
309        if options.metadata && self.store().trailer().contains_key(b"Info") {
310            self.store_mut().set_trailer_entry("Info", Object::Null);
311            report.metadata += 1;
312        }
313        self.invalidate();
314        Ok(report)
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn report_emptiness() {
324        assert!(SanitizeReport::default().is_empty());
325        assert!(
326            !SanitizeReport {
327                xfa: 1,
328                ..SanitizeReport::default()
329            }
330            .is_empty()
331        );
332        assert!(SanitizeOptions::ALL.links && !SanitizeOptions::default().links);
333    }
334}