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