1use std::collections::HashSet;
4
5use lopdf::{Document, Object, ObjectId};
6
7use crate::error::XfaError;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum JavaScriptPolicy {
17 AllowParse,
19 DenyExecution,
21 StripOnFlatten,
23}
24pub const ALLOW_PARSE: JavaScriptPolicy = JavaScriptPolicy::AllowParse;
26pub const DENY_EXECUTION: JavaScriptPolicy = JavaScriptPolicy::DenyExecution;
28pub const STRIP_ON_FLATTEN: JavaScriptPolicy = JavaScriptPolicy::StripOnFlatten;
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum JavaScriptEntryPoint {
34 PdfOpenAction,
36 AnnotationAdditionalAction,
38 FieldAction,
40 XfaEventHook,
42}
43
44impl JavaScriptEntryPoint {
45 pub fn as_str(self) -> &'static str {
47 match self {
48 Self::PdfOpenAction => "PDF /OpenAction JavaScript",
49 Self::AnnotationAdditionalAction => "annotation /AA JavaScript",
50 Self::FieldAction => "field /A JavaScript",
51 Self::XfaEventHook => "XFA event JavaScript",
52 }
53 }
54}
55pub fn parse_policy() -> JavaScriptPolicy {
57 ALLOW_PARSE
58}
59pub fn execution_policy(_entrypoint: JavaScriptEntryPoint) -> JavaScriptPolicy {
61 DENY_EXECUTION
62}
63pub fn flatten_policy(_entrypoint: JavaScriptEntryPoint) -> JavaScriptPolicy {
65 STRIP_ON_FLATTEN
66}
67pub fn reject_execution(entrypoint: JavaScriptEntryPoint) -> XfaError {
69 debug_assert_eq!(execution_policy(entrypoint), DENY_EXECUTION);
70 XfaError::UnsupportedFeature("javascript".to_string())
71}
72pub fn execution_denied_message(entrypoint: JavaScriptEntryPoint) -> String {
74 format!(
75 "{} denied by policy: JavaScript is parsed for inspection but never executed",
76 entrypoint.as_str()
77 )
78}
79pub fn template_mentions_javascript(template_xml: &str) -> bool {
81 let lower = template_xml.to_ascii_lowercase();
82 lower.contains("text/javascript")
83 || lower.contains("application/javascript")
84 || lower.contains("application/x-javascript")
85 || lower.contains("/x-javascript")
86}
87pub fn is_javascript_action_dict(dict: &lopdf::Dictionary) -> bool {
89 matches!(
90 dict.get(b"S").ok(),
91 Some(Object::Name(name)) if name == b"JavaScript"
92 )
93}
94pub fn catalog_has_javascript_open_action(doc: &Document) -> bool {
96 let Some(catalog_id) = catalog_id(doc) else {
97 return false;
98 };
99 let Some(Object::Dictionary(catalog)) = doc.objects.get(&catalog_id) else {
100 return false;
101 };
102 catalog
103 .get(b"OpenAction")
104 .ok()
105 .is_some_and(|action| object_is_javascript_action(doc, action))
106}
107pub fn dict_has_javascript_additional_actions(doc: &Document, dict: &lopdf::Dictionary) -> bool {
109 dict.get(b"AA")
110 .ok()
111 .is_some_and(|aa| additional_actions_contain_javascript(doc, aa))
112}
113pub fn dict_has_javascript_field_action(doc: &Document, dict: &lopdf::Dictionary) -> bool {
115 dict.get(b"A")
116 .ok()
117 .is_some_and(|action| object_is_javascript_action(doc, action))
118}
119
120pub fn strip_javascript_for_flatten(doc: &mut Document) -> usize {
125 debug_assert_eq!(
126 flatten_policy(JavaScriptEntryPoint::PdfOpenAction),
127 STRIP_ON_FLATTEN
128 );
129
130 let mut count = 0;
131 count += strip_javascript_name_tree(doc);
132
133 if let Some(catalog_id) = catalog_id(doc) {
134 let remove_open_action = doc
135 .objects
136 .get(&catalog_id)
137 .and_then(|object| match object {
138 Object::Dictionary(catalog) => catalog.get(b"OpenAction").ok(),
139 _ => None,
140 })
141 .is_some_and(|action| object_is_javascript_action(doc, action));
142
143 if remove_open_action {
144 if let Some(Object::Dictionary(catalog)) = doc.objects.get_mut(&catalog_id) {
145 catalog.remove(b"OpenAction");
146 count += 1;
147 }
148 }
149 }
150
151 let ids: Vec<ObjectId> = doc.objects.keys().copied().collect();
161 let mut decisions: Vec<(ObjectId, StripDecision)> = Vec::new();
162 for id in ids {
163 let decision = match doc.objects.get(&id) {
164 Some(Object::Dictionary(dict)) => StripDecision {
165 is_js_action: is_javascript_action_dict(dict),
166 has_js_field_action: dict_has_javascript_field_action(doc, dict),
167 has_js_aa: dict_has_javascript_additional_actions(doc, dict),
168 },
169 _ => StripDecision::default(),
170 };
171 if decision.has_any() {
172 decisions.push((id, decision));
173 }
174 }
175
176 for (id, decision) in decisions {
177 if let Some(Object::Dictionary(dict)) = doc.objects.get_mut(&id) {
178 if decision.is_js_action {
179 dict.remove(b"JS");
180 dict.remove(b"S");
181 count += 1;
182 }
183 if decision.has_js_field_action {
184 dict.remove(b"A");
185 count += 1;
186 }
187 if decision.has_js_aa {
188 dict.remove(b"AA");
189 count += 1;
190 }
191 }
192 }
193
194 count
195}
196
197#[derive(Default, Clone, Copy)]
198struct StripDecision {
199 is_js_action: bool,
200 has_js_field_action: bool,
201 has_js_aa: bool,
202}
203
204impl StripDecision {
205 fn has_any(&self) -> bool {
206 self.is_js_action || self.has_js_field_action || self.has_js_aa
207 }
208}
209
210fn strip_javascript_name_tree(doc: &mut Document) -> usize {
211 let Some(catalog_id) = catalog_id(doc) else {
212 return 0;
213 };
214
215 let names_id = doc
216 .objects
217 .get(&catalog_id)
218 .and_then(|object| match object {
219 Object::Dictionary(catalog) => match catalog.get(b"Names").ok() {
220 Some(Object::Reference(id)) => Some(*id),
221 _ => None,
222 },
223 _ => None,
224 });
225
226 let mut count = 0;
227 if let Some(names_id) = names_id {
228 if let Some(Object::Dictionary(names)) = doc.objects.get_mut(&names_id) {
229 if names.has(b"JavaScript") {
230 names.remove(b"JavaScript");
231 count += 1;
232 }
233 }
234 }
235
236 let has_inline_js = doc.objects.get(&catalog_id).is_some_and(|object| {
237 matches!(object, Object::Dictionary(catalog) if matches!(
238 catalog.get(b"Names").ok(),
239 Some(Object::Dictionary(names)) if names.has(b"JavaScript")
240 ))
241 });
242
243 if has_inline_js {
244 if let Some(Object::Dictionary(catalog)) = doc.objects.get_mut(&catalog_id) {
245 if let Ok(Object::Dictionary(names)) = catalog.get_mut(b"Names") {
246 names.remove(b"JavaScript");
247 count += 1;
248 }
249 }
250 }
251
252 count
253}
254
255fn additional_actions_contain_javascript(doc: &Document, aa: &Object) -> bool {
256 ActionGraphWalk::default().additional_actions_contain_javascript(doc, aa, 0)
257}
258
259fn object_is_javascript_action(doc: &Document, action: &Object) -> bool {
260 ActionGraphWalk::default().object_is_javascript_action(doc, action, 0)
261}
262
263const MAX_ACTION_GRAPH_DEPTH: usize = 64;
264const MAX_ACTION_GRAPH_REFERENCES: usize = 128;
265
266#[derive(Default)]
267struct ActionGraphWalk {
268 visiting: HashSet<ObjectId>,
269 resolved_references: usize,
270}
271
272impl ActionGraphWalk {
273 fn additional_actions_contain_javascript(
274 &mut self,
275 doc: &Document,
276 aa: &Object,
277 depth: usize,
278 ) -> bool {
279 if depth > MAX_ACTION_GRAPH_DEPTH {
280 return true;
281 }
282
283 match aa {
284 Object::Dictionary(dict) => dict
285 .iter()
286 .any(|(_, action)| self.object_is_javascript_action(doc, action, depth + 1)),
287 Object::Reference(id) => {
288 self.referenced_additional_actions_contain_javascript(doc, *id, depth + 1)
289 }
290 _ => false,
291 }
292 }
293
294 fn referenced_additional_actions_contain_javascript(
295 &mut self,
296 doc: &Document,
297 id: ObjectId,
298 depth: usize,
299 ) -> bool {
300 if !self.enter_reference(id) {
301 return true;
302 }
303 let contains_js = doc.objects.get(&id).is_some_and(|object| {
304 self.additional_actions_contain_javascript(doc, object, depth + 1)
305 });
306 self.visiting.remove(&id);
307 contains_js
308 }
309
310 fn object_is_javascript_action(
311 &mut self,
312 doc: &Document,
313 action: &Object,
314 depth: usize,
315 ) -> bool {
316 if depth > MAX_ACTION_GRAPH_DEPTH {
317 return true;
318 }
319
320 match action {
321 Object::Dictionary(dict) => self.action_dict_contains_javascript(doc, dict, depth + 1),
322 Object::Reference(id) => {
323 self.referenced_action_contains_javascript(doc, *id, depth + 1)
324 }
325 Object::Array(actions) => actions
326 .iter()
327 .any(|action| self.object_is_javascript_action(doc, action, depth + 1)),
328 _ => false,
329 }
330 }
331
332 fn referenced_action_contains_javascript(
333 &mut self,
334 doc: &Document,
335 id: ObjectId,
336 depth: usize,
337 ) -> bool {
338 if !self.enter_reference(id) {
339 return true;
340 }
341 let contains_js = doc
342 .objects
343 .get(&id)
344 .is_some_and(|object| self.object_is_javascript_action(doc, object, depth + 1));
345 self.visiting.remove(&id);
346 contains_js
347 }
348
349 fn action_dict_contains_javascript(
350 &mut self,
351 doc: &Document,
352 dict: &lopdf::Dictionary,
353 depth: usize,
354 ) -> bool {
355 if is_javascript_action_dict(dict) {
356 return true;
357 }
358 dict.get(b"Next")
359 .ok()
360 .is_some_and(|next| self.object_is_javascript_action(doc, next, depth + 1))
361 }
362
363 fn enter_reference(&mut self, id: ObjectId) -> bool {
364 if self.resolved_references >= MAX_ACTION_GRAPH_REFERENCES {
365 return false;
366 }
367 if self.visiting.contains(&id) {
368 return false;
369 }
370 self.resolved_references += 1;
371 self.visiting.insert(id)
372 }
373}
374
375fn catalog_id(doc: &Document) -> Option<ObjectId> {
376 match doc.trailer.get(b"Root").ok()? {
377 Object::Reference(id) => Some(*id),
378 _ => None,
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use lopdf::{dictionary, Document, Object};
386
387 fn basic_doc_with_catalog(catalog: lopdf::Dictionary) -> Document {
388 let mut doc = Document::with_version("1.4");
389 let pages_id = doc.add_object(Object::Dictionary(dictionary! {
390 "Type" => Object::Name(b"Pages".to_vec()),
391 "Count" => Object::Integer(0),
392 "Kids" => Object::Array(vec![]),
393 }));
394 let mut catalog = catalog;
395 catalog.set("Type", Object::Name(b"Catalog".to_vec()));
396 catalog.set("Pages", Object::Reference(pages_id));
397 let catalog_id = doc.add_object(Object::Dictionary(catalog));
398 doc.trailer.set("Root", Object::Reference(catalog_id));
399 doc
400 }
401
402 fn js_action(source: &[u8]) -> Object {
403 Object::Dictionary(dictionary! {
404 "S" => Object::Name(b"JavaScript".to_vec()),
405 "JS" => Object::String(source.to_vec(), lopdf::StringFormat::Literal),
406 })
407 }
408
409 fn hide_action_dict(next: Option<Object>) -> lopdf::Dictionary {
410 let mut dict = dictionary! {
411 "S" => Object::Name(b"Hide".to_vec()),
412 };
413 if let Some(next) = next {
414 dict.set("Next", next);
415 }
416 dict
417 }
418
419 fn hide_action(next: Option<Object>) -> Object {
420 Object::Dictionary(hide_action_dict(next))
421 }
422
423 fn set_catalog_open_action(doc: &mut Document, action: Object) {
424 let catalog_id = catalog_id(doc).expect("catalog id");
425 let Some(Object::Dictionary(catalog)) = doc.objects.get_mut(&catalog_id) else {
426 panic!("catalog dictionary");
427 };
428 catalog.set("OpenAction", action);
429 }
430
431 fn catalog_has_open_action(doc: &Document) -> bool {
432 let catalog_id = catalog_id(doc).expect("catalog id");
433 matches!(
434 doc.objects.get(&catalog_id),
435 Some(Object::Dictionary(catalog)) if catalog.has(b"OpenAction")
436 )
437 }
438
439 #[test]
440 fn policy_constants_are_explicit() {
441 assert_eq!(parse_policy(), ALLOW_PARSE);
442 assert_eq!(
443 execution_policy(JavaScriptEntryPoint::XfaEventHook),
444 DENY_EXECUTION
445 );
446 assert_eq!(
447 flatten_policy(JavaScriptEntryPoint::AnnotationAdditionalAction),
448 STRIP_ON_FLATTEN
449 );
450 }
451
452 #[test]
453 fn reject_execution_returns_unsupported_javascript() {
454 let err = reject_execution(JavaScriptEntryPoint::XfaEventHook);
455 assert_eq!(format!("{err}"), "unsupported feature: javascript");
456 }
457
458 #[test]
459 fn detects_open_action_javascript() {
460 let doc = basic_doc_with_catalog(dictionary! {
461 "OpenAction" => js_action(b"app.alert('x')"),
462 });
463
464 assert!(catalog_has_javascript_open_action(&doc));
465 }
466
467 #[test]
468 fn strips_open_action_javascript_on_flatten() {
469 let mut doc = basic_doc_with_catalog(dictionary! {
470 "OpenAction" => js_action(b"app.alert('x')"),
471 });
472
473 assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
474 assert!(!catalog_has_javascript_open_action(&doc));
475 }
476
477 #[test]
478 fn strips_open_action_when_next_dict_contains_javascript() {
479 let mut doc = basic_doc_with_catalog(dictionary! {
480 "OpenAction" => hide_action(Some(js_action(b"app.alert('next')"))),
481 });
482
483 assert!(catalog_has_javascript_open_action(&doc));
484 assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
485 assert!(!catalog_has_open_action(&doc));
486 }
487
488 #[test]
489 fn strips_open_action_when_next_array_contains_javascript() {
490 let mut doc = basic_doc_with_catalog(dictionary! {
491 "OpenAction" => hide_action(Some(Object::Array(vec![
492 hide_action(None),
493 js_action(b"app.alert('array')"),
494 ]))),
495 });
496
497 assert!(catalog_has_javascript_open_action(&doc));
498 assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
499 assert!(!catalog_has_open_action(&doc));
500 }
501
502 #[test]
503 fn cyclic_next_chain_is_fail_closed_without_looping() {
504 let mut doc = basic_doc_with_catalog(dictionary! {});
505 let action_a_id = doc.new_object_id();
506 let action_b_id = doc.new_object_id();
507 doc.objects.insert(
508 action_a_id,
509 Object::Dictionary(hide_action_dict(Some(Object::Reference(action_b_id)))),
510 );
511 doc.objects.insert(
512 action_b_id,
513 Object::Dictionary(hide_action_dict(Some(Object::Reference(action_a_id)))),
514 );
515 set_catalog_open_action(&mut doc, Object::Reference(action_a_id));
516
517 assert!(catalog_has_javascript_open_action(&doc));
518 assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
519 assert!(!catalog_has_open_action(&doc));
520 }
521
522 #[test]
523 fn detects_annotation_additional_action_javascript() {
524 let doc = basic_doc_with_catalog(dictionary! {});
525 let annot = dictionary! {
526 "Type" => Object::Name(b"Annot".to_vec()),
527 "Subtype" => Object::Name(b"Widget".to_vec()),
528 "AA" => Object::Dictionary(dictionary! {
529 "E" => js_action(b"app.alert('enter')"),
530 }),
531 };
532
533 assert!(dict_has_javascript_additional_actions(&doc, &annot));
534 }
535
536 #[test]
537 fn strips_additional_action_entry_with_next_chain_javascript() {
538 let mut doc = basic_doc_with_catalog(dictionary! {});
539 let action_id = doc.add_object(hide_action(Some(js_action(
540 b"app.alert('additional action')",
541 ))));
542 let annot_id = doc.add_object(Object::Dictionary(dictionary! {
543 "Type" => Object::Name(b"Annot".to_vec()),
544 "Subtype" => Object::Name(b"Widget".to_vec()),
545 "AA" => Object::Dictionary(dictionary! {
546 "U" => Object::Reference(action_id),
547 }),
548 }));
549
550 let annot = match doc.objects.get(&annot_id) {
551 Some(Object::Dictionary(annot)) => annot,
552 _ => panic!("annotation dictionary"),
553 };
554 assert!(dict_has_javascript_additional_actions(&doc, annot));
555
556 assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
557 let annot = match doc.objects.get(&annot_id) {
558 Some(Object::Dictionary(annot)) => annot,
559 _ => panic!("annotation dictionary"),
560 };
561 assert!(!annot.has(b"AA"));
562 }
563
564 #[test]
565 fn detects_field_action_javascript() {
566 let doc = basic_doc_with_catalog(dictionary! {});
567 let field = dictionary! {
568 "FT" => Object::Name(b"Tx".to_vec()),
569 "A" => js_action(b"app.alert('field')"),
570 };
571
572 assert!(dict_has_javascript_field_action(&doc, &field));
573 }
574
575 #[test]
576 fn strips_field_action_with_next_chain_javascript() {
577 let mut doc = basic_doc_with_catalog(dictionary! {});
578 let field_id = doc.add_object(Object::Dictionary(dictionary! {
579 "FT" => Object::Name(b"Tx".to_vec()),
580 "A" => hide_action(Some(js_action(b"app.alert('field next')"))),
581 }));
582
583 assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
584 let field = match doc.objects.get(&field_id) {
585 Some(Object::Dictionary(field)) => field,
586 _ => panic!("field dictionary"),
587 };
588 assert!(!field.has(b"A"));
589 }
590
591 #[test]
592 fn pure_non_javascript_action_chain_is_preserved() {
593 let mut doc = basic_doc_with_catalog(dictionary! {
594 "OpenAction" => hide_action(Some(hide_action(Some(hide_action(None))))),
595 });
596
597 assert!(!catalog_has_javascript_open_action(&doc));
598 assert_eq!(strip_javascript_for_flatten(&mut doc), 0);
599 assert!(catalog_has_open_action(&doc));
600 }
601
602 #[test]
603 fn malformed_javascript_payload_is_never_parsed_for_execution() {
604 let mut doc = basic_doc_with_catalog(dictionary! {
605 "OpenAction" => js_action(b"\0}\xff{not valid js"),
606 });
607
608 assert!(catalog_has_javascript_open_action(&doc));
609 assert_eq!(strip_javascript_for_flatten(&mut doc), 1);
610 }
611
612 #[test]
613 fn indirect_field_action_to_javascript_is_always_stripped() {
614 let mut doc = basic_doc_with_catalog(dictionary! {});
622 let js_action_id = doc.add_object(js_action(b"app.alert('indirect')"));
623 let field_id = doc.add_object(Object::Dictionary(dictionary! {
624 "FT" => Object::Name(b"Tx".to_vec()),
625 "A" => Object::Reference(js_action_id),
626 }));
627
628 let count = strip_javascript_for_flatten(&mut doc);
629 assert_eq!(
631 count, 2,
632 "both field /A and JS action body must be stripped"
633 );
634
635 let field = match doc.objects.get(&field_id) {
636 Some(Object::Dictionary(field)) => field,
637 _ => panic!("field dictionary"),
638 };
639 assert!(
640 !field.has(b"A"),
641 "field /A pointing indirectly to JS action must be stripped"
642 );
643
644 let js_obj = match doc.objects.get(&js_action_id) {
645 Some(Object::Dictionary(action)) => action,
646 _ => panic!("js action dictionary"),
647 };
648 assert!(
649 !js_obj.has(b"JS"),
650 "indirect JS action /JS payload must be stripped"
651 );
652 assert!(
653 !js_obj.has(b"S"),
654 "indirect JS action /S name must be stripped"
655 );
656 }
657
658 #[test]
659 fn strip_outcome_is_independent_of_object_insertion_order() {
660 let mut doc_action_first = basic_doc_with_catalog(dictionary! {});
665 let action_id_a = doc_action_first.add_object(js_action(b"app.alert('A')"));
666 let field_id_a = doc_action_first.add_object(Object::Dictionary(dictionary! {
667 "FT" => Object::Name(b"Tx".to_vec()),
668 "A" => Object::Reference(action_id_a),
669 }));
670
671 let mut doc_field_first = basic_doc_with_catalog(dictionary! {});
672 let field_id_b = doc_field_first.add_object(Object::Dictionary(dictionary! {
673 "FT" => Object::Name(b"Tx".to_vec()),
674 "A" => Object::Reference(lopdf::ObjectId::default()), }));
676 let action_id_b = doc_field_first.add_object(js_action(b"app.alert('A')"));
677 if let Some(Object::Dictionary(d)) = doc_field_first.objects.get_mut(&field_id_b) {
679 d.set("A", Object::Reference(action_id_b));
680 }
681
682 let count_a = strip_javascript_for_flatten(&mut doc_action_first);
683 let count_b = strip_javascript_for_flatten(&mut doc_field_first);
684 assert_eq!(count_a, count_b, "strip count must match across orderings");
685
686 for (doc, fid, aid) in [
687 (&doc_action_first, field_id_a, action_id_a),
688 (&doc_field_first, field_id_b, action_id_b),
689 ] {
690 let field = match doc.objects.get(&fid) {
691 Some(Object::Dictionary(f)) => f,
692 _ => panic!("field"),
693 };
694 assert!(
695 !field.has(b"A"),
696 "field /A must be stripped in both orderings"
697 );
698 let act = match doc.objects.get(&aid) {
699 Some(Object::Dictionary(a)) => a,
700 _ => panic!("action"),
701 };
702 assert!(
703 !act.has(b"JS"),
704 "action /JS must be stripped in both orderings"
705 );
706 assert!(
707 !act.has(b"S"),
708 "action /S must be stripped in both orderings"
709 );
710 }
711 }
712
713 #[test]
714 fn non_javascript_action_chain_with_indirect_targets_is_preserved() {
715 let mut doc = basic_doc_with_catalog(dictionary! {});
719 let inner_hide_id = doc.add_object(hide_action(None));
720 let outer_hide = hide_action(Some(Object::Reference(inner_hide_id)));
721 let field_id = doc.add_object(Object::Dictionary(dictionary! {
722 "FT" => Object::Name(b"Tx".to_vec()),
723 "A" => outer_hide,
724 }));
725
726 assert_eq!(
727 strip_javascript_for_flatten(&mut doc),
728 0,
729 "non-JS action chain must be preserved"
730 );
731
732 let field = match doc.objects.get(&field_id) {
733 Some(Object::Dictionary(f)) => f,
734 _ => panic!("field"),
735 };
736 assert!(field.has(b"A"), "non-JS /A must remain on field");
737 let inner = match doc.objects.get(&inner_hide_id) {
738 Some(Object::Dictionary(a)) => a,
739 _ => panic!("inner hide action"),
740 };
741 assert!(inner.has(b"S"), "non-JS action /S must remain");
742 }
743}