1use std::borrow::Cow;
23use std::cell::Cell;
24use std::collections::BTreeMap;
25
26use base64::{Engine as _, engine::general_purpose::STANDARD};
27use roxmltree::{Document, Node};
28use sha2::{Digest as _, Sha256};
29
30use super::parse::XMLDSIG_NS;
31use super::types::{NodeSetMaterializationBudget, TransformData, TransformError};
32use super::whitespace::is_xml_whitespace_only;
33use super::xpath::{
34 XPathDocumentRelation, XPathWorkBudget, apply_xpath_filter_with_semantics_and_budget,
35 apply_xpath_filter2_with_semantics_and_budget, compile_xpath, is_xpath_whitespace,
36};
37use crate::c14n::{self, C14nAlgorithm};
38
39pub const ENVELOPED_SIGNATURE_URI: &str = "http://www.w3.org/2000/09/xmldsig#enveloped-signature";
41pub const BASE64_TRANSFORM_URI: &str = "http://www.w3.org/2000/09/xmldsig#base64";
43pub const XPATH_TRANSFORM_URI: &str = "http://www.w3.org/TR/1999/REC-xpath-19991116";
45pub const XPATH_FILTER2_TRANSFORM_URI: &str = "http://www.w3.org/2002/06/xmldsig-filter2";
47pub const DEFAULT_IMPLICIT_C14N_URI: &str = "http://www.w3.org/TR/2001/REC-xml-c14n-20010315";
50pub const MAX_TRANSFORMS_PER_REFERENCE: usize = 64;
56const ENVELOPED_SIGNATURE_XPATH_EXPR: &str = "not(ancestor-or-self::dsig:Signature)";
59pub(super) const MAX_XPATH_EXPRESSION_BYTES: usize = 16 * 1024;
60pub(super) const MAX_XPATH_FILTERS: usize = 64;
61pub(super) const MAX_XPATH_EXPRESSIONS_PER_SIGNATURE: usize = 4_096;
66const MAX_XPATH_NAMESPACE_BINDINGS: usize = 1_024;
67const MAX_XPATH_NAMESPACE_BYTES: usize = 64 * 1024;
68const MAX_BASE64_TRANSFORM_INPUT_BYTES: usize = 16 * 1024 * 1024;
69const MAX_BASE64_TRANSFORM_OUTPUT_BYTES: usize = 8 * 1024 * 1024;
70const MAX_C14N_OUTPUT_BYTES: usize = 16 * 1024 * 1024;
71const MAX_NODE_SET_FILTER_WORK: usize = 6_000_000;
73
74const EXCLUSIVE_C14N_NS_URI: &str = "http://www.w3.org/2001/10/xml-exc-c14n#";
76
77#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
79pub enum XPathHereSemantics {
80 #[default]
83 Specification,
84 XmlSecLegacy,
89}
90
91#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub struct TransformOptions {
94 xpath_here_semantics: XPathHereSemantics,
95}
96
97#[derive(Default)]
98pub(crate) struct TransformExecutionBudget {
99 xpath: XPathWorkBudget,
100 base64: Base64WorkBudget,
101 c14n: C14nOutputBudget,
102 node_filter: NodeFilterWorkBudget,
103 node_set_materialization: NodeSetMaterializationBudget,
104}
105
106struct NodeFilterWorkBudget {
107 remaining: Cell<usize>,
108}
109
110impl Default for NodeFilterWorkBudget {
111 fn default() -> Self {
112 Self {
113 remaining: Cell::new(MAX_NODE_SET_FILTER_WORK),
114 }
115 }
116}
117
118impl NodeFilterWorkBudget {
119 fn charge(&self, entries: usize) -> Result<(), TransformError> {
120 if !charge_byte_budget(&self.remaining, entries) {
121 return Err(TransformError::NodeSetFilterWorkTooLarge {
122 max_entries: MAX_NODE_SET_FILTER_WORK,
123 });
124 }
125 Ok(())
126 }
127}
128
129struct Base64WorkBudget {
130 remaining: Cell<usize>,
131}
132
133struct C14nOutputBudget {
134 remaining: Cell<usize>,
135}
136
137fn charge_byte_budget(remaining: &Cell<usize>, bytes: usize) -> bool {
138 let Some(next) = remaining.get().checked_sub(bytes) else {
139 remaining.set(0);
140 return false;
141 };
142 remaining.set(next);
143 true
144}
145
146impl Default for C14nOutputBudget {
147 fn default() -> Self {
148 Self {
149 remaining: Cell::new(MAX_C14N_OUTPUT_BYTES),
150 }
151 }
152}
153
154impl C14nOutputBudget {
155 fn remaining(&self) -> usize {
156 self.remaining.get()
157 }
158
159 fn charge(&self, bytes: usize) -> Result<(), TransformError> {
160 if !charge_byte_budget(&self.remaining, bytes) {
161 return Err(TransformError::C14nOutputTooLarge {
162 max_bytes: MAX_C14N_OUTPUT_BYTES,
163 });
164 }
165 Ok(())
166 }
167}
168
169impl Default for Base64WorkBudget {
170 fn default() -> Self {
171 Self {
172 remaining: Cell::new(MAX_BASE64_TRANSFORM_INPUT_BYTES),
173 }
174 }
175}
176
177impl Base64WorkBudget {
178 fn charge(&self, bytes: usize) -> Result<(), TransformError> {
179 if !charge_byte_budget(&self.remaining, bytes) {
180 return Err(TransformError::Base64InputTooLarge {
181 max_bytes: MAX_BASE64_TRANSFORM_INPUT_BYTES,
182 });
183 }
184 Ok(())
185 }
186}
187
188#[cfg(test)]
189impl TransformExecutionBudget {
190 pub(crate) fn with_xpath_limit(limit: usize) -> Self {
191 Self {
192 xpath: XPathWorkBudget::with_limit(limit),
193 base64: Base64WorkBudget::default(),
194 c14n: C14nOutputBudget::default(),
195 node_filter: NodeFilterWorkBudget::default(),
196 node_set_materialization: NodeSetMaterializationBudget::default(),
197 }
198 }
199
200 fn with_c14n_limit(limit: usize) -> Self {
201 Self {
202 xpath: XPathWorkBudget::default(),
203 base64: Base64WorkBudget::default(),
204 c14n: C14nOutputBudget {
205 remaining: Cell::new(limit),
206 },
207 node_filter: NodeFilterWorkBudget::default(),
208 node_set_materialization: NodeSetMaterializationBudget::default(),
209 }
210 }
211
212 fn with_node_filter_limit(limit: usize) -> Self {
213 Self {
214 xpath: XPathWorkBudget::default(),
215 base64: Base64WorkBudget::default(),
216 c14n: C14nOutputBudget::default(),
217 node_filter: NodeFilterWorkBudget {
218 remaining: Cell::new(limit),
219 },
220 node_set_materialization: NodeSetMaterializationBudget::default(),
221 }
222 }
223
224 pub(crate) fn with_node_set_materialization_limit(limit: usize) -> Self {
225 Self {
226 xpath: XPathWorkBudget::default(),
227 base64: Base64WorkBudget::default(),
228 c14n: C14nOutputBudget::default(),
229 node_filter: NodeFilterWorkBudget::default(),
230 node_set_materialization: NodeSetMaterializationBudget::with_limit(limit),
231 }
232 }
233}
234
235impl TransformExecutionBudget {
236 pub(crate) fn node_set_materialization(&self) -> &NodeSetMaterializationBudget {
237 &self.node_set_materialization
238 }
239}
240
241impl TransformOptions {
242 #[must_use]
244 pub fn xpath_here_semantics(mut self, semantics: XPathHereSemantics) -> Self {
245 self.xpath_here_semantics = semantics;
246 self
247 }
248
249 pub(crate) fn here_semantics(self) -> XPathHereSemantics {
250 self.xpath_here_semantics
251 }
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255struct XPathHereNodes {
256 specification_xpath_element: roxmltree::NodeId,
257 xmlsec_legacy_transform_element: roxmltree::NodeId,
258 document: XPathDocumentIdentity,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262struct XPathDocumentIdentity([u8; 32]);
263
264impl XPathDocumentIdentity {
265 fn from_document(document: &Document<'_>) -> Self {
266 #[cfg(test)]
267 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(count.get() + 1));
268 Self(Sha256::digest(document.input_text().as_bytes()).into())
269 }
270}
271
272#[cfg(test)]
273thread_local! {
274 static XPATH_DOCUMENT_IDENTITY_COMPUTATIONS: Cell<usize> = const { Cell::new(0) };
275}
276
277#[derive(Default)]
278struct TransformChainState {
279 xpath_document_identity: Cell<Option<CachedXPathDocumentIdentity>>,
280}
281
282#[derive(Clone, Copy)]
283struct CachedXPathDocumentIdentity {
284 document: *const (),
285 identity: XPathDocumentIdentity,
286}
287
288impl TransformChainState {
289 fn xpath_document_identity(&self, document: &Document<'_>) -> XPathDocumentIdentity {
290 let document_key = std::ptr::from_ref(document).cast::<()>();
291 if let Some(cached) = self.xpath_document_identity.get()
292 && cached.document == document_key
293 {
294 return cached.identity;
295 }
296 let identity = XPathDocumentIdentity::from_document(document);
297 self.xpath_document_identity
298 .set(Some(CachedXPathDocumentIdentity {
299 document: document_key,
300 identity,
301 }));
302 identity
303 }
304
305 fn document_reparsed(&self) {
306 self.xpath_document_identity.set(None);
307 }
308}
309
310struct TransformExecutionContext<'a> {
311 options: TransformOptions,
312 budget: &'a TransformExecutionBudget,
313 state: &'a TransformChainState,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct XPathExpression {
319 expression: String,
320 namespaces: BTreeMap<String, String>,
321 here_nodes: Option<XPathHereNodes>,
322}
323
324impl XPathExpression {
325 pub fn new(expression: impl Into<String>) -> Self {
327 Self {
328 expression: expression.into(),
329 namespaces: BTreeMap::new(),
330 here_nodes: None,
331 }
332 }
333
334 pub fn with_namespace(mut self, prefix: impl Into<String>, uri: impl Into<String>) -> Self {
336 self.namespaces.insert(prefix.into(), uri.into());
337 self
338 }
339
340 pub fn expression(&self) -> &str {
342 &self.expression
343 }
344
345 pub fn namespaces(&self) -> &BTreeMap<String, String> {
347 &self.namespaces
348 }
349
350 pub(crate) fn here_context_node(
351 &self,
352 semantics: XPathHereSemantics,
353 ) -> Option<roxmltree::NodeId> {
354 self.here_nodes.map(|nodes| match semantics {
355 XPathHereSemantics::Specification => nodes.specification_xpath_element,
356 XPathHereSemantics::XmlSecLegacy => nodes.xmlsec_legacy_transform_element,
357 })
358 }
359
360 fn parsed_document_identity(&self) -> Option<XPathDocumentIdentity> {
361 self.here_nodes.map(|nodes| nodes.document)
362 }
363}
364
365#[derive(Debug, Clone, Copy, PartialEq, Eq)]
367pub enum XPathFilterOperation {
368 Intersect,
370 Subtract,
372 Union,
374}
375
376impl XPathFilterOperation {
377 pub(crate) fn as_str(self) -> &'static str {
378 match self {
379 Self::Intersect => "intersect",
380 Self::Subtract => "subtract",
381 Self::Union => "union",
382 }
383 }
384}
385
386#[derive(Debug, Clone, PartialEq, Eq)]
388pub struct XPathFilter {
389 operation: XPathFilterOperation,
390 xpath: XPathExpression,
391}
392
393impl XPathFilter {
394 pub fn new(operation: XPathFilterOperation, xpath: XPathExpression) -> Self {
396 Self { operation, xpath }
397 }
398
399 pub fn operation(&self) -> XPathFilterOperation {
401 self.operation
402 }
403
404 pub fn xpath(&self) -> &XPathExpression {
406 &self.xpath
407 }
408}
409
410#[derive(Debug, Clone)]
412pub enum Transform {
413 Enveloped,
418
419 XpathExcludeAllSignatures,
425
426 XPath(XPathExpression),
428
429 XPathFilter2(Vec<XPathFilter>),
431
432 C14n(C14nAlgorithm),
436
437 Base64Decode,
445}
446
447#[cfg(test)]
455pub(crate) fn apply_transform<'a>(
456 signature_node: Node<'a, 'a>,
457 transform: &Transform,
458 input: TransformData<'a>,
459) -> Result<TransformData<'a>, TransformError> {
460 let budget = TransformExecutionBudget::default();
461 let state = TransformChainState::default();
462 apply_transform_with_options_and_state(
463 signature_node,
464 transform,
465 input,
466 TransformOptions::default(),
467 &budget,
468 &state,
469 )
470}
471
472#[cfg(test)]
473pub(super) fn apply_transform_with_options<'s, 'd>(
474 signature_node: Node<'s, 's>,
475 transform: &Transform,
476 input: TransformData<'d>,
477 options: TransformOptions,
478 budget: &TransformExecutionBudget,
479) -> Result<TransformData<'d>, TransformError> {
480 let state = TransformChainState::default();
481 apply_transform_with_options_and_state(
482 signature_node,
483 transform,
484 input,
485 options,
486 budget,
487 &state,
488 )
489}
490
491fn apply_transform_with_options_and_state<'s, 'd>(
492 signature_node: Node<'s, 's>,
493 transform: &Transform,
494 input: TransformData<'d>,
495 options: TransformOptions,
496 budget: &TransformExecutionBudget,
497 state: &TransformChainState,
498) -> Result<TransformData<'d>, TransformError> {
499 match transform {
500 Transform::Enveloped => {
501 let mut nodes = input.into_node_set()?;
502 if !std::ptr::eq(signature_node.document(), nodes.document()) {
511 return Err(TransformError::CrossDocumentSignatureNode);
512 }
513 budget.node_filter.charge(nodes.len())?;
514 nodes.exclude_subtree(signature_node);
515 Ok(TransformData::NodeSet(nodes))
516 }
517 Transform::XpathExcludeAllSignatures => {
518 let mut nodes = input.into_node_set()?;
519 let doc = nodes.document();
520
521 for node in doc.descendants().filter(|node| {
522 node.is_element()
523 && node.tag_name().name() == "Signature"
524 && node.tag_name().namespace() == Some(XMLDSIG_NS)
525 }) {
526 budget.node_filter.charge(nodes.len())?;
527 nodes.exclude_subtree(node);
528 }
529
530 Ok(TransformData::NodeSet(nodes))
531 }
532 Transform::XPath(xpath) => {
533 let nodes = input.into_node_set()?;
534 let document_relation = xpath_document_relation(
535 signature_node.document(),
536 nodes.document(),
537 std::iter::once(xpath),
538 state,
539 );
540 Ok(TransformData::NodeSet(
541 apply_xpath_filter_with_semantics_and_budget(
542 nodes,
543 xpath,
544 options.here_semantics(),
545 document_relation,
546 &budget.xpath,
547 &budget.node_set_materialization,
548 )?,
549 ))
550 }
551 Transform::XPathFilter2(filters) => {
552 let nodes = input.into_node_set()?;
553 let document_relation = xpath_document_relation(
554 signature_node.document(),
555 nodes.document(),
556 filters.iter().map(XPathFilter::xpath),
557 state,
558 );
559 Ok(TransformData::NodeSet(
560 apply_xpath_filter2_with_semantics_and_budget(
561 nodes,
562 filters,
563 options.here_semantics(),
564 document_relation,
565 &budget.xpath,
566 &budget.node_set_materialization,
567 )?,
568 ))
569 }
570 Transform::C14n(algo) => {
571 let nodes = input.into_node_set()?;
572 let mut output = Vec::new();
573 c14n::canonicalize_with_visibility_and_position_bounded(
574 nodes.document(),
575 Some(&nodes),
576 algo,
577 None,
578 budget.c14n.remaining(),
579 &mut output,
580 )
581 .map_err(map_c14n_limit_error)?;
582 budget.c14n.charge(output.len())?;
583 Ok(TransformData::Binary(output))
584 }
585 Transform::Base64Decode => {
586 let mut normalized = Vec::new();
587 match input {
588 TransformData::Binary(bytes) => {
589 append_normalized_base64(&bytes, &mut normalized, &budget.base64)?;
590 }
591 TransformData::NodeSet(nodes) => {
592 for node in nodes.document().descendants() {
593 if nodes.contains(node) && node.is_text() {
594 append_normalized_base64(
595 node.text().unwrap_or_default().as_bytes(),
596 &mut normalized,
597 &budget.base64,
598 )?;
599 }
600 }
601 }
602 }
603 Ok(TransformData::Binary(decode_base64_transform(&normalized)?))
604 }
605 }
606}
607
608fn xpath_document_relation<'a>(
609 signature_document: &Document<'_>,
610 input_document: &Document<'_>,
611 expressions: impl IntoIterator<Item = &'a XPathExpression>,
612 state: &TransformChainState,
613) -> XPathDocumentRelation {
614 if matches!(
615 XPathDocumentRelation::between(signature_document, input_document),
616 XPathDocumentRelation::CrossDocument
617 ) {
618 return XPathDocumentRelation::CrossDocument;
619 }
620
621 let mut parsed_identities = expressions
622 .into_iter()
623 .filter_map(XPathExpression::parsed_document_identity);
624 let Some(first) = parsed_identities.next() else {
625 return XPathDocumentRelation::SameDocument;
626 };
627 let input_identity = state.xpath_document_identity(input_document);
628 if first == input_identity && parsed_identities.all(|identity| identity == input_identity) {
629 XPathDocumentRelation::SameDocument
630 } else {
631 XPathDocumentRelation::CrossDocument
632 }
633}
634
635fn append_normalized_base64(
641 encoded: &[u8],
642 normalized: &mut Vec<u8>,
643 budget: &Base64WorkBudget,
644) -> Result<(), TransformError> {
645 budget.charge(encoded.len())?;
646
647 let additional = encoded
648 .iter()
649 .filter(|byte| is_rfc2045_base64_byte(**byte))
650 .count();
651 normalized.reserve(additional);
652 normalized.extend(
653 encoded
654 .iter()
655 .copied()
656 .filter(|byte| is_rfc2045_base64_byte(*byte)),
657 );
658 Ok(())
659}
660
661fn is_rfc2045_base64_byte(byte: u8) -> bool {
662 byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=')
663}
664
665fn decode_base64_transform(normalized: &[u8]) -> Result<Vec<u8>, TransformError> {
666 let padding = normalized
667 .iter()
668 .rev()
669 .take_while(|byte| **byte == b'=')
670 .count();
671 let decoded_len = base64::decoded_len_estimate(normalized.len()).saturating_sub(padding);
672 if decoded_len > MAX_BASE64_TRANSFORM_OUTPUT_BYTES {
673 return Err(TransformError::Base64OutputTooLarge {
674 max_bytes: MAX_BASE64_TRANSFORM_OUTPUT_BYTES,
675 });
676 }
677
678 let mut decoded = vec![0_u8; decoded_len];
679 let written = STANDARD
680 .decode_slice(normalized, &mut decoded)
681 .map_err(|error| TransformError::Base64(error.to_string()))?;
682 decoded.truncate(written);
683 Ok(decoded)
684}
685
686pub fn execute_transforms<'a>(
695 signature_node: Node<'a, 'a>,
696 initial_data: TransformData<'a>,
697 transforms: &[Transform],
698) -> Result<Vec<u8>, TransformError> {
699 execute_transforms_with_options(
700 signature_node,
701 initial_data,
702 transforms,
703 TransformOptions::default(),
704 )
705}
706
707pub fn execute_transforms_with_options<'a>(
709 signature_node: Node<'a, 'a>,
710 initial_data: TransformData<'a>,
711 transforms: &[Transform],
712 options: TransformOptions,
713) -> Result<Vec<u8>, TransformError> {
714 let budget = TransformExecutionBudget::default();
715 execute_transforms_with_options_and_budget(
716 signature_node,
717 initial_data,
718 transforms,
719 options,
720 &budget,
721 )
722}
723
724pub(crate) fn execute_transforms_with_options_and_budget<'a>(
725 signature_node: Node<'a, 'a>,
726 initial_data: TransformData<'a>,
727 transforms: &[Transform],
728 options: TransformOptions,
729 budget: &TransformExecutionBudget,
730) -> Result<Vec<u8>, TransformError> {
731 ensure_transform_count(transforms.len())?;
732 let state = TransformChainState::default();
733 let context = TransformExecutionContext {
734 options,
735 budget,
736 state: &state,
737 };
738 execute_transform_chain(
739 signature_node,
740 Some(signature_node),
741 initial_data,
742 transforms,
743 None,
744 &context,
745 )
746}
747
748fn ensure_transform_count(count: usize) -> Result<(), TransformError> {
749 if count > MAX_TRANSFORMS_PER_REFERENCE {
750 return Err(TransformError::TooManyTransforms {
751 max: MAX_TRANSFORMS_PER_REFERENCE,
752 });
753 }
754 Ok(())
755}
756
757fn execute_transform_chain<'s, 'e, 'd>(
758 source_signature: Node<'s, 's>,
759 enveloped_signature: Option<Node<'e, 'e>>,
760 data: TransformData<'d>,
761 transforms: &[Transform],
762 canonical_signature_position: Option<Option<usize>>,
763 context: &TransformExecutionContext<'_>,
764) -> Result<Vec<u8>, TransformError> {
765 let Some((transform, remaining)) = transforms.split_first() else {
766 return finalize_transform_data(data, &context.budget.c14n);
767 };
768
769 if transform_requires_node_set(transform)
770 && let TransformData::Binary(bytes) = data
771 {
772 let xml = decode_xml_octets(&bytes)?;
778 let document = roxmltree::Document::parse(&xml)
779 .map_err(|error| TransformError::XmlParse(error.to_string()))?;
780 context.state.document_reparsed();
781 let nodes = super::types::NodeSet::entire_document_with_comments_with_budget(
782 &document,
783 &context.budget.node_set_materialization,
784 )?;
785 return match canonical_signature_position {
786 Some(Some(position)) => {
787 let remapped = document
788 .descendants()
789 .find(|node| node.is_element() && node.range().start == position)
790 .filter(|node| {
791 enveloped_signature
792 .is_some_and(|source| node.tag_name() == source.tag_name())
793 })
794 .ok_or(TransformError::CrossDocumentSignatureNode)?;
795 execute_transform_chain(
796 source_signature,
797 Some(remapped),
798 TransformData::NodeSet(nodes),
799 transforms,
800 None,
801 context,
802 )
803 }
804 Some(None) => execute_transform_chain(
805 source_signature,
806 None,
807 TransformData::NodeSet(nodes),
808 transforms,
809 None,
810 context,
811 ),
812 None => execute_transform_chain(
813 source_signature,
814 None,
818 TransformData::NodeSet(nodes),
819 transforms,
820 None,
821 context,
822 ),
823 };
824 }
825
826 if let Transform::C14n(algo) = transform
827 && let TransformData::NodeSet(nodes) = &data
828 {
829 let tracked_element = enveloped_signature
830 .filter(|signature| std::ptr::eq(signature.document(), nodes.document()))
831 .filter(|signature| nodes.contains(*signature))
832 .map(|signature| signature.id());
833 let mut output = Vec::new();
834 let position = c14n::canonicalize_with_visibility_and_position_bounded(
835 nodes.document(),
836 Some(nodes),
837 algo,
838 tracked_element,
839 context.budget.c14n.remaining(),
840 &mut output,
841 )
842 .map_err(map_c14n_limit_error)?;
843 context.budget.c14n.charge(output.len())?;
844 return execute_transform_chain(
845 source_signature,
846 enveloped_signature,
847 TransformData::Binary(output),
848 remaining,
849 Some(position),
850 context,
851 );
852 }
853
854 if matches!(transform, Transform::Enveloped) {
855 let Some(signature) = enveloped_signature else {
856 return execute_transform_chain(source_signature, None, data, remaining, None, context);
857 };
858 let data = apply_transform_with_options_and_state(
859 signature,
860 transform,
861 data,
862 context.options,
863 context.budget,
864 context.state,
865 )?;
866 return execute_transform_chain(
867 source_signature,
868 Some(signature),
869 data,
870 remaining,
871 None,
872 context,
873 );
874 }
875
876 let data = apply_transform_with_options_and_state(
877 source_signature,
878 transform,
879 data,
880 context.options,
881 context.budget,
882 context.state,
883 )?;
884 execute_transform_chain(
885 source_signature,
886 enveloped_signature,
887 data,
888 remaining,
889 None,
890 context,
891 )
892}
893
894fn decode_xml_octets(bytes: &[u8]) -> Result<Cow<'_, str>, TransformError> {
895 let (utf16, little_endian) = if let Some(payload) = bytes.strip_prefix(&[0xff, 0xfe]) {
898 (Some(payload), true)
899 } else if let Some(payload) = bytes.strip_prefix(&[0xfe, 0xff]) {
900 (Some(payload), false)
901 } else {
902 (None, false)
903 };
904 if let Some(payload) = utf16 {
905 if payload.len() % 2 != 0 {
906 return Err(TransformError::XmlParse(
907 "UTF-16 XML input has an odd byte length".into(),
908 ));
909 }
910 let code_units = payload
911 .chunks_exact(2)
912 .map(|chunk| {
913 let bytes = [chunk[0], chunk[1]];
914 if little_endian {
915 u16::from_le_bytes(bytes)
916 } else {
917 u16::from_be_bytes(bytes)
918 }
919 })
920 .collect::<Vec<_>>();
921 return String::from_utf16(&code_units)
922 .map(Cow::Owned)
923 .map_err(|error| TransformError::XmlParse(error.to_string()));
924 }
925
926 let payload = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes);
927 std::str::from_utf8(payload)
928 .map(Cow::Borrowed)
929 .map_err(|error| TransformError::XmlParse(error.to_string()))
930}
931
932fn transform_requires_node_set(transform: &Transform) -> bool {
933 !matches!(transform, Transform::Base64Decode)
934}
935
936fn finalize_transform_data(
937 data: TransformData<'_>,
938 c14n_budget: &C14nOutputBudget,
939) -> Result<Vec<u8>, TransformError> {
940 match data {
943 TransformData::Binary(bytes) => Ok(bytes),
944 TransformData::NodeSet(nodes) => {
945 #[expect(clippy::expect_used, reason = "hardcoded URI is a known constant")]
946 let algo = C14nAlgorithm::from_uri(DEFAULT_IMPLICIT_C14N_URI)
947 .expect("default C14N algorithm URI must be supported by C14nAlgorithm::from_uri");
948 let mut output = Vec::new();
949 c14n::canonicalize_with_visibility_and_position_bounded(
950 nodes.document(),
951 Some(&nodes),
952 &algo,
953 None,
954 c14n_budget.remaining(),
955 &mut output,
956 )
957 .map_err(map_c14n_limit_error)?;
958 c14n_budget.charge(output.len())?;
959 Ok(output)
960 }
961 }
962}
963
964fn map_c14n_limit_error(error: c14n::C14nError) -> TransformError {
965 if c14n::is_output_limit_error(&error) {
966 TransformError::C14nOutputTooLarge {
967 max_bytes: MAX_C14N_OUTPUT_BYTES,
968 }
969 } else {
970 TransformError::C14n(error)
971 }
972}
973
974pub fn parse_transforms(transforms_node: Node) -> Result<Vec<Transform>, TransformError> {
983 parse_transforms_with_budget(transforms_node, &mut XPathSignatureParseBudget::default())
984}
985
986pub(crate) fn parse_transforms_with_budget(
987 transforms_node: Node,
988 signature_budget: &mut XPathSignatureParseBudget,
989) -> Result<Vec<Transform>, TransformError> {
990 if !transforms_node.is_element() {
992 return Err(TransformError::UnsupportedTransform(
993 "expected <Transforms> element but got non-element node".into(),
994 ));
995 }
996 let transforms_tag = transforms_node.tag_name();
997 if transforms_tag.name() != "Transforms" || transforms_tag.namespace() != Some(XMLDSIG_NS) {
998 return Err(TransformError::UnsupportedTransform(
999 "expected <ds:Transforms> element in XMLDSig namespace".into(),
1000 ));
1001 }
1002
1003 let mut chain = Vec::new();
1004 let mut xpath_state = XPathParseState::new(signature_budget);
1005
1006 for child in transforms_node.children() {
1007 if !child.is_element() {
1008 continue;
1009 }
1010 ensure_transform_count(chain.len() + 1)?;
1011
1012 let tag = child.tag_name();
1014 if tag.name() != "Transform" || tag.namespace() != Some(XMLDSIG_NS) {
1015 return Err(TransformError::UnsupportedTransform(
1016 "unexpected child element of <ds:Transforms>; only <ds:Transform> is allowed"
1017 .into(),
1018 ));
1019 }
1020 let uri = child.attribute("Algorithm").ok_or_else(|| {
1021 TransformError::UnsupportedTransform(
1022 "missing Algorithm attribute on <Transform>".into(),
1023 )
1024 })?;
1025
1026 let transform = if uri == ENVELOPED_SIGNATURE_URI {
1027 Transform::Enveloped
1028 } else if uri == BASE64_TRANSFORM_URI {
1029 validate_empty_transform(child, "Base64")?;
1030 Transform::Base64Decode
1031 } else if uri == XPATH_TRANSFORM_URI {
1032 parse_xpath_transform_with_state(child, &mut xpath_state)?
1033 } else if uri == XPATH_FILTER2_TRANSFORM_URI {
1034 parse_xpath_filter2_transform(child, &mut xpath_state)?
1035 } else if let Some(mut algo) = C14nAlgorithm::from_uri(uri) {
1036 if algo.mode() == c14n::C14nMode::Exclusive1_0
1038 && let Some(prefix_list) = parse_inclusive_prefixes(child)?
1039 {
1040 algo = algo.with_prefix_list(&prefix_list);
1041 }
1042 Transform::C14n(algo)
1043 } else {
1044 return Err(TransformError::UnsupportedTransform(uri.to_string()));
1045 };
1046 chain.push(transform);
1047 }
1048
1049 Ok(chain)
1050}
1051
1052fn validate_empty_transform(
1054 transform_node: Node,
1055 transform_name: &'static str,
1056) -> Result<(), TransformError> {
1057 for child in transform_node.children() {
1058 if child.is_element()
1059 || (child.is_text()
1060 && child
1061 .text()
1062 .is_some_and(|text| !is_xml_whitespace_only(text)))
1063 {
1064 return Err(TransformError::UnsupportedTransform(format!(
1065 "{transform_name} transform must not contain parameters"
1066 )));
1067 }
1068 }
1069 Ok(())
1070}
1071
1072#[cfg(test)]
1073pub(super) fn parse_xpath_transform(transform_node: Node) -> Result<Transform, TransformError> {
1074 parse_xpath_transform_with_state(
1075 transform_node,
1076 &mut XPathParseState::new(&mut XPathSignatureParseBudget::default()),
1077 )
1078}
1079
1080fn parse_xpath_transform_with_state(
1081 transform_node: Node,
1082 xpath_state: &mut XPathParseState,
1083) -> Result<Transform, TransformError> {
1084 let mut xpath_node = None;
1085
1086 for child in transform_node.children() {
1087 if child.is_text() && child.text().is_some_and(is_xml_whitespace_only) {
1088 continue;
1089 }
1090 if child.is_comment() || child.is_pi() {
1091 continue;
1092 }
1093 if !child.is_element() {
1094 return Err(TransformError::XPath(
1095 "XPath transform contains non-whitespace parameter content".into(),
1096 ));
1097 }
1098 let tag = child.tag_name();
1099 if tag.name() == "XPath" && tag.namespace() == Some(XMLDSIG_NS) {
1100 if xpath_node.is_some() {
1101 return Err(TransformError::XPath(
1102 "XPath transform must contain exactly one XMLDSig <XPath> child element".into(),
1103 ));
1104 }
1105 xpath_node = Some(child);
1106 } else {
1107 return Err(TransformError::XPath(
1108 "XPath transform allows only a single XMLDSig <XPath> child element".into(),
1109 ));
1110 }
1111 }
1112
1113 let xpath_node = xpath_node.ok_or_else(|| {
1114 TransformError::XPath(
1115 "XPath transform requires a single XMLDSig <XPath> child element".into(),
1116 )
1117 })?;
1118 if xpath_node.attributes().len() != 0 {
1119 return Err(TransformError::XPath(
1120 "XMLDSig <XPath> does not allow attributes".into(),
1121 ));
1122 }
1123 let xpath = parse_xpath_expression(xpath_node, transform_node.id(), xpath_state)?;
1124
1125 if xpath.expression() == ENVELOPED_SIGNATURE_XPATH_EXPR
1126 && xpath.namespaces().get("dsig").map(String::as_str) == Some(XMLDSIG_NS)
1127 {
1128 Ok(Transform::XpathExcludeAllSignatures)
1129 } else {
1130 Ok(Transform::XPath(xpath))
1131 }
1132}
1133
1134fn parse_xpath_filter2_transform(
1135 transform_node: Node,
1136 xpath_state: &mut XPathParseState,
1137) -> Result<Transform, TransformError> {
1138 let mut filters = Vec::new();
1139 for child in transform_node.children() {
1140 if child.is_text() && child.text().is_some_and(is_xml_whitespace_only) {
1141 continue;
1142 }
1143 if child.is_comment() || child.is_pi() {
1144 continue;
1145 }
1146 if !child.is_element()
1147 || child.tag_name().name() != "XPath"
1148 || child.tag_name().namespace() != Some(XPATH_FILTER2_TRANSFORM_URI)
1149 {
1150 return Err(TransformError::XPath(
1151 "XPath Filter 2.0 allows only filter-namespace <XPath> children".into(),
1152 ));
1153 }
1154 if filters.len() == MAX_XPATH_FILTERS {
1155 return Err(TransformError::XPath(format!(
1156 "XPath Filter 2.0 exceeds the maximum of {MAX_XPATH_FILTERS} expressions"
1157 )));
1158 }
1159 if child.attributes().len() != 1 || child.attribute("Filter").is_none() {
1160 return Err(TransformError::XPath(
1161 "XPath Filter 2.0 <XPath> requires only the unqualified Filter attribute".into(),
1162 ));
1163 }
1164 let operation = match child.attribute("Filter") {
1165 Some("intersect") => XPathFilterOperation::Intersect,
1166 Some("subtract") => XPathFilterOperation::Subtract,
1167 Some("union") => XPathFilterOperation::Union,
1168 Some(value) => {
1169 return Err(TransformError::XPath(format!(
1170 "unsupported XPath Filter 2.0 operation: {value}"
1171 )));
1172 }
1173 None => unreachable!("Filter presence was checked above"),
1174 };
1175 filters.push(XPathFilter::new(
1176 operation,
1177 parse_xpath_expression(child, transform_node.id(), xpath_state)?,
1178 ));
1179 }
1180 if filters.is_empty() {
1181 return Err(TransformError::XPath(
1182 "XPath Filter 2.0 requires at least one expression".into(),
1183 ));
1184 }
1185 Ok(Transform::XPathFilter2(filters))
1186}
1187
1188fn parse_xpath_expression(
1189 xpath_node: Node,
1190 transform_node: roxmltree::NodeId,
1191 xpath_state: &mut XPathParseState,
1192) -> Result<XPathExpression, TransformError> {
1193 let mut source = String::new();
1194 for child in xpath_node.children() {
1195 if child.is_text() {
1196 let text = child.text().unwrap_or_default();
1197 if source
1198 .len()
1199 .checked_add(text.len())
1200 .is_none_or(|length| length > MAX_XPATH_EXPRESSION_BYTES)
1201 {
1202 return Err(TransformError::XPath(format!(
1203 "XPath expression exceeds {MAX_XPATH_EXPRESSION_BYTES} bytes"
1204 )));
1205 }
1206 source.push_str(text);
1207 } else if child.is_element() {
1208 return Err(TransformError::XPath(
1209 "XPath expressions must contain text only".into(),
1210 ));
1211 }
1212 }
1213 let source = source.trim_matches(is_xpath_whitespace);
1214 if source.is_empty() {
1215 return Err(TransformError::XPath(
1216 "XPath expression must not be empty".into(),
1217 ));
1218 }
1219 xpath_state.signature_budget.charge()?;
1220 compile_xpath(source).map_err(TransformError::XPath)?;
1221
1222 let mut xpath = XPathExpression {
1223 expression: source.to_owned(),
1224 namespaces: BTreeMap::new(),
1225 here_nodes: Some(XPathHereNodes {
1226 specification_xpath_element: xpath_node.id(),
1229 xmlsec_legacy_transform_element: transform_node,
1230 document: xpath_state.document_identity(xpath_node.document()),
1234 }),
1235 };
1236 for namespace in xpath_node.namespaces() {
1237 if let Some(prefix) = namespace.name() {
1238 xpath_state
1239 .namespace_budget
1240 .charge(prefix, namespace.uri())?;
1241 xpath
1242 .namespaces
1243 .insert(prefix.to_owned(), namespace.uri().to_owned());
1244 }
1245 }
1246 Ok(xpath)
1247}
1248
1249struct XPathParseState<'a> {
1250 namespace_budget: XPathNamespaceBudget,
1251 document_identity: Option<XPathDocumentIdentity>,
1252 signature_budget: &'a mut XPathSignatureParseBudget,
1253}
1254
1255impl<'a> XPathParseState<'a> {
1256 fn new(signature_budget: &'a mut XPathSignatureParseBudget) -> Self {
1257 Self {
1258 namespace_budget: XPathNamespaceBudget::default(),
1259 document_identity: None,
1260 signature_budget,
1261 }
1262 }
1263
1264 fn document_identity(&mut self, document: &Document<'_>) -> XPathDocumentIdentity {
1265 *self
1266 .document_identity
1267 .get_or_insert_with(|| XPathDocumentIdentity::from_document(document))
1268 }
1269}
1270
1271#[derive(Default)]
1272pub(crate) struct XPathSignatureParseBudget {
1274 expressions: usize,
1275}
1276
1277impl XPathSignatureParseBudget {
1278 pub(crate) fn charge(&mut self) -> Result<(), TransformError> {
1279 self.expressions = self.expressions.checked_add(1).ok_or_else(Self::error)?;
1280 if self.expressions > MAX_XPATH_EXPRESSIONS_PER_SIGNATURE {
1281 return Err(Self::error());
1282 }
1283 Ok(())
1284 }
1285
1286 fn error() -> TransformError {
1287 TransformError::XPath(format!(
1288 "signature-wide XPath expression budget exceeds {MAX_XPATH_EXPRESSIONS_PER_SIGNATURE} entries"
1289 ))
1290 }
1291}
1292
1293#[derive(Default)]
1294struct XPathNamespaceBudget {
1295 bindings: usize,
1296 bytes: usize,
1297}
1298
1299impl XPathNamespaceBudget {
1300 fn charge(&mut self, prefix: &str, uri: &str) -> Result<(), TransformError> {
1301 self.bindings = self.bindings.checked_add(1).ok_or_else(Self::error)?;
1302 self.bytes = self
1303 .bytes
1304 .checked_add(prefix.len())
1305 .and_then(|bytes| bytes.checked_add(uri.len()))
1306 .ok_or_else(Self::error)?;
1307 if self.bindings > MAX_XPATH_NAMESPACE_BINDINGS || self.bytes > MAX_XPATH_NAMESPACE_BYTES {
1308 return Err(Self::error());
1309 }
1310 Ok(())
1311 }
1312
1313 fn error() -> TransformError {
1314 TransformError::XPath(format!(
1315 "XPath namespace binding budget exceeds {MAX_XPATH_NAMESPACE_BINDINGS} entries or \
1316 {MAX_XPATH_NAMESPACE_BYTES} bytes per transform chain"
1317 ))
1318 }
1319}
1320
1321pub(crate) fn validate_xpath_namespace_budget(
1322 transforms: &[Transform],
1323 inherited_namespace: Option<(&str, &str)>,
1324) -> Result<(), TransformError> {
1325 let mut budget = XPathNamespaceBudget::default();
1326 for transform in transforms {
1327 match transform {
1328 Transform::XPath(xpath) => {
1329 for (prefix, uri) in xpath.namespaces() {
1330 budget.charge(prefix, uri)?;
1331 }
1332 if let Some((prefix, uri)) = inherited_namespace
1333 && !xpath.namespaces().contains_key(prefix)
1334 {
1335 budget.charge(prefix, uri)?;
1336 }
1337 }
1338 Transform::XPathFilter2(filters) => {
1339 for filter in filters {
1340 for (prefix, uri) in filter.xpath().namespaces() {
1341 budget.charge(prefix, uri)?;
1342 }
1343 if let Some((prefix, uri)) = inherited_namespace
1344 && !filter.xpath().namespaces().contains_key(prefix)
1345 {
1346 budget.charge(prefix, uri)?;
1347 }
1348 }
1349 }
1350 _ => {}
1351 }
1352 }
1353 Ok(())
1354}
1355
1356fn parse_inclusive_prefixes(transform_node: Node) -> Result<Option<String>, TransformError> {
1374 for child in transform_node.children() {
1375 if child.is_element() {
1376 let tag = child.tag_name();
1377 if tag.name() == "InclusiveNamespaces" && tag.namespace() == Some(EXCLUSIVE_C14N_NS_URI)
1378 {
1379 let prefix_list = child.attribute("PrefixList").ok_or_else(|| {
1380 TransformError::UnsupportedTransform(
1381 "missing PrefixList attribute on <InclusiveNamespaces>".into(),
1382 )
1383 })?;
1384 return Ok(Some(prefix_list.to_string()));
1385 }
1386 }
1387 }
1388 Ok(None)
1389}
1390
1391#[cfg(test)]
1392#[expect(clippy::unwrap_used, reason = "tests use trusted XML fixtures")]
1393mod tests {
1394 use super::*;
1395 use crate::xmldsig::NodeSet;
1396 use roxmltree::Document;
1397
1398 #[test]
1401 fn enveloped_excludes_signature_subtree() {
1402 let xml = r#"<root>
1404 <data>hello</data>
1405 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
1406 <SignedInfo><Reference URI=""/></SignedInfo>
1407 <SignatureValue>abc</SignatureValue>
1408 </Signature>
1409 </root>"#;
1410 let doc = Document::parse(xml).unwrap();
1411
1412 let sig_node = doc
1414 .descendants()
1415 .find(|n| n.is_element() && n.tag_name().name() == "Signature")
1416 .unwrap();
1417
1418 let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
1420 let data = TransformData::NodeSet(node_set);
1421
1422 let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
1424 let node_set = result.into_node_set().unwrap();
1425
1426 assert!(node_set.contains(doc.root_element()));
1428 let data_elem = doc
1429 .descendants()
1430 .find(|n| n.is_element() && n.tag_name().name() == "data")
1431 .unwrap();
1432 assert!(node_set.contains(data_elem));
1433
1434 assert!(
1436 !node_set.contains(sig_node),
1437 "Signature element should be excluded"
1438 );
1439 let signed_info = doc
1440 .descendants()
1441 .find(|n| n.is_element() && n.tag_name().name() == "SignedInfo")
1442 .unwrap();
1443 assert!(
1444 !node_set.contains(signed_info),
1445 "SignedInfo (child of Signature) should be excluded"
1446 );
1447 }
1448
1449 #[test]
1450 fn enveloped_requires_node_set_input() {
1451 let xml = "<root/>";
1452 let doc = Document::parse(xml).unwrap();
1453 let data = TransformData::Binary(vec![1, 2, 3]);
1455 let result = apply_transform(doc.root_element(), &Transform::Enveloped, data);
1456 assert!(result.is_err());
1457 match result.unwrap_err() {
1458 TransformError::TypeMismatch { expected, got } => {
1459 assert_eq!(expected, "NodeSet");
1460 assert_eq!(got, "Binary");
1461 }
1462 other => panic!("expected TypeMismatch, got: {other:?}"),
1463 }
1464 }
1465
1466 #[test]
1467 fn enveloped_rejects_cross_document_signature_node() {
1468 let xml = r#"<Root><Signature Id="sig"/></Root>"#;
1471 let doc1 = Document::parse(xml).unwrap();
1472 let doc2 = Document::parse(xml).unwrap();
1473
1474 let node_set = NodeSet::entire_document_without_comments(&doc1).unwrap();
1476 let input = TransformData::NodeSet(node_set);
1477 let sig_from_doc2 = doc2
1478 .descendants()
1479 .find(|n| n.is_element() && n.tag_name().name() == "Signature")
1480 .unwrap();
1481
1482 let result = apply_transform(sig_from_doc2, &Transform::Enveloped, input);
1483 assert!(matches!(
1484 result,
1485 Err(TransformError::CrossDocumentSignatureNode)
1486 ));
1487 }
1488
1489 #[test]
1492 fn c14n_transform_produces_bytes() {
1493 let xml = r#"<root b="2" a="1"><child/></root>"#;
1494 let doc = Document::parse(xml).unwrap();
1495
1496 let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
1497 let data = TransformData::NodeSet(node_set);
1498
1499 let algo =
1500 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1501 let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data).unwrap();
1502
1503 let bytes = result.into_binary().unwrap();
1504 let output = String::from_utf8(bytes).unwrap();
1505 assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
1507 }
1508
1509 #[test]
1510 fn c14n_transform_requires_node_set() {
1511 let xml = "<root/>";
1512 let doc = Document::parse(xml).unwrap();
1513
1514 let algo =
1515 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1516 let data = TransformData::Binary(vec![1, 2, 3]);
1517 let result = apply_transform(doc.root_element(), &Transform::C14n(algo), data);
1518
1519 assert!(result.is_err());
1520 assert!(matches!(
1521 result.unwrap_err(),
1522 TransformError::TypeMismatch { .. }
1523 ));
1524 }
1525
1526 #[test]
1529 fn base64_transform_decodes_binary_with_xml_whitespace() {
1530 let doc = Document::parse("<root/>").unwrap();
1532 let input = TransformData::Binary(b" SGV\tsbG8=\r\n".to_vec());
1533
1534 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
1535
1536 assert_eq!(result.into_binary().unwrap(), b"Hello");
1537 }
1538
1539 #[test]
1540 fn base64_transform_concatenates_only_selected_text_nodes_in_document_order() {
1541 let xml = r#"<root><Data ID="payload">SGV<!-- split --><Part>sb</Part><?pi ignored?>G8=</Data></root>"#;
1544 let doc = Document::parse(xml).unwrap();
1545 let data = doc
1546 .descendants()
1547 .find(|node| node.attribute("ID") == Some("payload"))
1548 .unwrap();
1549 let input = TransformData::NodeSet(NodeSet::subtree(data).unwrap());
1550
1551 let result = apply_transform(data, &Transform::Base64Decode, input).unwrap();
1552
1553 assert_eq!(result.into_binary().unwrap(), b"Hello");
1554 }
1555
1556 #[test]
1557 fn base64_transform_omits_text_excluded_from_the_node_set() {
1558 let xml = "<root>SGV<Excluded>QUJD</Excluded>sbG8=</root>";
1561 let doc = Document::parse(xml).unwrap();
1562 let excluded = doc
1563 .descendants()
1564 .find(|node| node.has_tag_name("Excluded"))
1565 .unwrap();
1566 let mut nodes = NodeSet::subtree(doc.root_element()).unwrap();
1567 nodes.exclude_subtree(excluded);
1568
1569 let result = apply_transform(
1570 doc.root_element(),
1571 &Transform::Base64Decode,
1572 TransformData::NodeSet(nodes),
1573 )
1574 .unwrap();
1575
1576 assert_eq!(result.into_binary().unwrap(), b"Hello");
1577 }
1578
1579 #[test]
1580 fn base64_transform_ignores_rfc2045_non_alphabet_bytes() {
1581 let doc = Document::parse("<root/>").unwrap();
1582 let input = TransformData::Binary(b"SGVs!\xFFbG8=".to_vec());
1583
1584 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
1585
1586 assert_eq!(result.into_binary().unwrap(), b"Hello");
1587 }
1588
1589 #[test]
1590 fn base64_transform_rejects_invalid_padding() {
1591 let doc = Document::parse("<root/>").unwrap();
1592 let result = apply_transform(
1593 doc.root_element(),
1594 &Transform::Base64Decode,
1595 TransformData::Binary(b"SGVsbG8===".to_vec()),
1596 );
1597
1598 assert!(matches!(result, Err(TransformError::Base64(_))));
1599 }
1600
1601 #[test]
1602 fn base64_transform_accepts_empty_input() {
1603 let doc = Document::parse("<root/>").unwrap();
1604 let result = apply_transform(
1605 doc.root_element(),
1606 &Transform::Base64Decode,
1607 TransformData::Binary(Vec::new()),
1608 )
1609 .unwrap();
1610
1611 assert!(result.into_binary().unwrap().is_empty());
1612 }
1613
1614 #[test]
1615 fn base64_transform_rejects_oversized_raw_binary_before_normalization() {
1616 let doc = Document::parse("<root/>").unwrap();
1619 let input = TransformData::Binary(vec![b' '; MAX_BASE64_TRANSFORM_INPUT_BYTES + 1]);
1620
1621 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input);
1622
1623 assert!(matches!(
1624 result,
1625 Err(TransformError::Base64InputTooLarge {
1626 max_bytes: MAX_BASE64_TRANSFORM_INPUT_BYTES
1627 })
1628 ));
1629 }
1630
1631 #[test]
1632 fn base64_transform_rejects_node_set_that_decodes_past_output_budget() {
1633 let encoded_len = MAX_BASE64_TRANSFORM_OUTPUT_BYTES.div_ceil(3) * 4 + 4;
1636 let xml = format!("<root>{}</root>", "A".repeat(encoded_len));
1637 let doc = Document::parse(&xml).unwrap();
1638 let input = TransformData::NodeSet(NodeSet::subtree(doc.root_element()).unwrap());
1639
1640 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input);
1641
1642 assert!(matches!(
1643 result,
1644 Err(TransformError::Base64OutputTooLarge {
1645 max_bytes: MAX_BASE64_TRANSFORM_OUTPUT_BYTES
1646 })
1647 ));
1648 }
1649
1650 #[test]
1651 fn base64_transform_handles_highly_fragmented_node_set_input() {
1652 let expected = vec![0x42_u8; 3 * 1_024];
1656 let encoded = STANDARD.encode(&expected);
1657 let mut xml = String::from("<root>");
1658 for byte in encoded.bytes() {
1659 xml.push(char::from(byte));
1660 xml.push_str("<!-- split -->");
1661 }
1662 xml.push_str("</root>");
1663 let doc = Document::parse(&xml).unwrap();
1664 let input = TransformData::NodeSet(NodeSet::subtree(doc.root_element()).unwrap());
1665
1666 let result = apply_transform(doc.root_element(), &Transform::Base64Decode, input).unwrap();
1667
1668 assert_eq!(result.into_binary().unwrap(), expected);
1669 }
1670
1671 #[test]
1672 fn pipeline_rejects_cumulative_base64_input_past_budget() {
1673 let doc = Document::parse("<root/>").unwrap();
1676 let inner = vec![b'A'; MAX_BASE64_TRANSFORM_OUTPUT_BYTES];
1677 let outer = STANDARD.encode(&inner);
1678 let transforms = [Transform::Base64Decode, Transform::Base64Decode];
1679
1680 let result = execute_transforms(
1681 doc.root_element(),
1682 TransformData::Binary(outer.into_bytes()),
1683 &transforms,
1684 );
1685
1686 assert!(matches!(
1687 result,
1688 Err(TransformError::Base64InputTooLarge {
1689 max_bytes: MAX_BASE64_TRANSFORM_INPUT_BYTES
1690 })
1691 ));
1692 }
1693
1694 #[test]
1695 fn pipeline_rejects_unbounded_programmatic_transform_chain() {
1696 let doc = Document::parse("<root/>").unwrap();
1699 let transforms = vec![Transform::Base64Decode; 65];
1700
1701 let result = execute_transforms(
1702 doc.root_element(),
1703 TransformData::Binary(Vec::new()),
1704 &transforms,
1705 );
1706
1707 assert!(matches!(
1708 result,
1709 Err(TransformError::TooManyTransforms {
1710 max: MAX_TRANSFORMS_PER_REFERENCE
1711 })
1712 ));
1713 }
1714
1715 #[test]
1718 fn byte_budgets_remain_exhausted_after_overflow() {
1719 let c14n = C14nOutputBudget::default();
1722 assert!(c14n.charge(MAX_C14N_OUTPUT_BYTES + 1).is_err());
1723 assert!(c14n.charge(1).is_err());
1724
1725 let base64 = Base64WorkBudget::default();
1726 assert!(base64.charge(MAX_BASE64_TRANSFORM_INPUT_BYTES + 1).is_err());
1727 assert!(base64.charge(1).is_err());
1728 }
1729
1730 #[test]
1731 fn pipeline_rejects_cumulative_c14n_output() {
1732 let xml = format!("<root>{}</root>", "x".repeat(512 * 1024));
1736 let document = Document::parse(&xml).unwrap();
1737 let algorithm =
1738 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1739 let transforms = vec![Transform::C14n(algorithm); 40];
1740
1741 let result = execute_transforms(
1742 document.root_element(),
1743 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
1744 &transforms,
1745 );
1746
1747 assert!(matches!(
1748 result,
1749 Err(TransformError::C14nOutputTooLarge {
1750 max_bytes: MAX_C14N_OUTPUT_BYTES
1751 })
1752 ));
1753 }
1754
1755 #[test]
1756 fn binary_to_node_set_adapter_uses_shared_materialization_budget() {
1757 let signature_document = Document::parse("<Signature/>").unwrap();
1761 let budget = TransformExecutionBudget::with_node_set_materialization_limit(1);
1762 let transforms = [Transform::XPath(XPathExpression::new("true()"))];
1763
1764 let error = execute_transforms_with_options_and_budget(
1765 signature_document.root_element(),
1766 TransformData::Binary(b"<root xmlns:n=\"urn:namespace\"/>".to_vec()),
1767 &transforms,
1768 TransformOptions::default(),
1769 &budget,
1770 )
1771 .expect_err("the binary adapter must charge cloned namespace strings");
1772
1773 assert!(matches!(
1774 error,
1775 TransformError::NodeSetCumulativeStringsTooLarge { .. }
1776 ));
1777 }
1778
1779 #[test]
1780 fn xpath_projection_uses_shared_materialization_budget() {
1781 let document = Document::parse("<root attribute=\"value\"/>").unwrap();
1785 let budget = TransformExecutionBudget::with_node_set_materialization_limit(1);
1786 let transforms = [Transform::XPath(XPathExpression::new("true()"))];
1787
1788 let error = execute_transforms_with_options_and_budget(
1789 document.root_element(),
1790 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap()),
1791 &transforms,
1792 TransformOptions::default(),
1793 &budget,
1794 )
1795 .expect_err("XPath projection must charge cloned attribute names");
1796
1797 assert!(matches!(
1798 error,
1799 TransformError::NodeSetCumulativeStringsTooLarge { .. }
1800 ));
1801 }
1802
1803 #[test]
1804 fn explicit_and_implicit_c14n_stop_at_the_execution_ceiling() {
1805 let xml = format!("<root>{}</root>", "x".repeat(4_096));
1808 let document = Document::parse(&xml).unwrap();
1809 let nodes = || {
1810 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
1811 };
1812 let algorithm =
1813 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1814
1815 for transforms in [&[][..], &[Transform::C14n(algorithm)][..]] {
1816 let error = execute_transforms_with_options_and_budget(
1817 document.root_element(),
1818 nodes(),
1819 transforms,
1820 TransformOptions::default(),
1821 &TransformExecutionBudget::with_c14n_limit(64),
1822 )
1823 .expect_err("canonicalization must stop at the execution ceiling");
1824
1825 assert!(matches!(
1826 error,
1827 TransformError::C14nOutputTooLarge {
1828 max_bytes: MAX_C14N_OUTPUT_BYTES
1829 }
1830 ));
1831 }
1832 }
1833
1834 #[test]
1835 fn execution_budget_bounds_c14n_output_across_references() {
1836 let xml = format!("<root>{}</root>", "x".repeat(512 * 1024));
1840 let document = Document::parse(&xml).unwrap();
1841 let algorithm =
1842 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap();
1843 let transforms = vec![Transform::C14n(algorithm); 20];
1844 let execution_budget = TransformExecutionBudget::default();
1845 let input = || {
1846 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
1847 };
1848
1849 execute_transforms_with_options_and_budget(
1850 document.root_element(),
1851 input(),
1852 &transforms,
1853 TransformOptions::default(),
1854 &execution_budget,
1855 )
1856 .expect("the first Reference must fit the cumulative C14N output budget");
1857 let result = execute_transforms_with_options_and_budget(
1858 document.root_element(),
1859 input(),
1860 &transforms,
1861 TransformOptions::default(),
1862 &execution_budget,
1863 );
1864
1865 assert!(matches!(
1866 result,
1867 Err(TransformError::C14nOutputTooLarge {
1868 max_bytes: MAX_C14N_OUTPUT_BYTES
1869 })
1870 ));
1871 }
1872
1873 #[test]
1874 fn execution_budget_bounds_repeated_node_set_exclusions() {
1875 let document =
1878 Document::parse("<root><payload/><Signature><Object/></Signature></root>").unwrap();
1879 let signature = document
1880 .descendants()
1881 .find(|node| node.has_tag_name("Signature"))
1882 .unwrap();
1883 let input = || NodeSet::entire_document_with_comments(&document).unwrap();
1884 let entries_per_exclusion = input().len();
1885 let budget = TransformExecutionBudget::with_node_filter_limit(
1886 entries_per_exclusion.saturating_mul(2).saturating_sub(1),
1887 );
1888
1889 execute_transforms_with_options_and_budget(
1890 signature,
1891 TransformData::NodeSet(input()),
1892 &[Transform::Enveloped],
1893 TransformOptions::default(),
1894 &budget,
1895 )
1896 .expect("the first reference exclusion must fit the shared budget");
1897 let result = execute_transforms_with_options_and_budget(
1898 signature,
1899 TransformData::NodeSet(input()),
1900 &[Transform::Enveloped],
1901 TransformOptions::default(),
1902 &budget,
1903 );
1904
1905 assert!(
1906 matches!(
1907 result,
1908 Err(TransformError::NodeSetFilterWorkTooLarge { .. })
1909 ),
1910 "the second reference exclusion must exhaust the shared budget"
1911 );
1912 }
1913
1914 #[test]
1915 fn execution_budget_bounds_implicit_c14n_across_references() {
1916 let xml = format!("<root>{}</root>", "x".repeat(6 * 1024 * 1024));
1920 let document = Document::parse(&xml).unwrap();
1921 let execution_budget = TransformExecutionBudget::default();
1922 let input = || {
1923 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap())
1924 };
1925
1926 for _ in 0..2 {
1927 execute_transforms_with_options_and_budget(
1928 document.root_element(),
1929 input(),
1930 &[],
1931 TransformOptions::default(),
1932 &execution_budget,
1933 )
1934 .expect("two implicit C14N outputs must fit the shared budget");
1935 }
1936 let result = execute_transforms_with_options_and_budget(
1937 document.root_element(),
1938 input(),
1939 &[],
1940 TransformOptions::default(),
1941 &execution_budget,
1942 );
1943
1944 assert!(matches!(
1945 result,
1946 Err(TransformError::C14nOutputTooLarge {
1947 max_bytes: MAX_C14N_OUTPUT_BYTES
1948 })
1949 ));
1950 }
1951
1952 #[test]
1953 fn pipeline_enveloped_then_c14n() {
1954 let xml = r#"<root xmlns:ns="http://example.com" b="2" a="1">
1956 <data>hello</data>
1957 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
1958 <SignedInfo/>
1959 <SignatureValue>abc</SignatureValue>
1960 </Signature>
1961 </root>"#;
1962 let doc = Document::parse(xml).unwrap();
1963
1964 let sig_node = doc
1965 .descendants()
1966 .find(|n| n.is_element() && n.tag_name().name() == "Signature")
1967 .unwrap();
1968
1969 let initial =
1970 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
1971 let transforms = vec![
1972 Transform::Enveloped,
1973 Transform::C14n(
1974 C14nAlgorithm::from_uri("http://www.w3.org/2001/10/xml-exc-c14n#").unwrap(),
1975 ),
1976 ];
1977
1978 let result = execute_transforms(sig_node, initial, &transforms).unwrap();
1979
1980 let output = String::from_utf8(result).unwrap();
1981 assert!(!output.contains("Signature"));
1983 assert!(!output.contains("SignedInfo"));
1984 assert!(!output.contains("SignatureValue"));
1985 assert!(output.contains("<data>hello</data>"));
1986 }
1987
1988 #[test]
1989 fn pipeline_c14n_then_enveloped_remaps_the_exact_signature() {
1990 let xml = r#"<root>
1994 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="other"/>
1995 <data>hello</data>
1996 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="owner"/>
1997 </root>"#;
1998 let document = Document::parse(xml).unwrap();
1999 let signature = document
2000 .descendants()
2001 .find(|node| node.attribute("Id") == Some("owner"))
2002 .unwrap();
2003 let initial =
2004 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap());
2005 let transforms = vec![
2006 Transform::C14n(
2007 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
2008 ),
2009 Transform::Enveloped,
2010 ];
2011
2012 let output = execute_transforms(signature, initial, &transforms).unwrap();
2013 let output = String::from_utf8(output).unwrap();
2014
2015 assert!(output.contains("Id=\"other\""));
2016 assert!(!output.contains("Id=\"owner\""));
2017 assert!(output.contains("<data>hello</data>"));
2018 }
2019
2020 #[test]
2021 fn pipeline_remaps_signature_after_xpath_removes_an_earlier_sibling() {
2022 let xml = r#"<root>
2025 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="other"/>
2026 <discard/>
2027 <Signature xmlns="http://www.w3.org/2000/09/xmldsig#" Id="owner"/>
2028 </root>"#;
2029 let document = Document::parse(xml).unwrap();
2030 let signature = document
2031 .descendants()
2032 .find(|node| node.attribute("Id") == Some("owner"))
2033 .unwrap();
2034 let initial =
2035 TransformData::NodeSet(NodeSet::entire_document_without_comments(&document).unwrap());
2036 let transforms = vec![
2037 Transform::XPath(XPathExpression::new("not(self::discard)")),
2038 Transform::C14n(
2039 C14nAlgorithm::from_uri("http://www.w3.org/TR/2001/REC-xml-c14n-20010315").unwrap(),
2040 ),
2041 Transform::Enveloped,
2042 ];
2043
2044 let output = execute_transforms(signature, initial, &transforms).unwrap();
2045 let output = String::from_utf8(output).unwrap();
2046
2047 assert!(output.contains("Id=\"other\""));
2048 assert!(!output.contains("Id=\"owner\""));
2049 assert!(!output.contains("discard"));
2050 }
2051
2052 #[test]
2053 fn pipeline_enveloped_ignores_signature_absent_after_base64_adaptation() {
2054 let source = Document::parse(
2058 r#"<root><Signature xmlns="http://www.w3.org/2000/09/xmldsig#"/></root>"#,
2059 )
2060 .unwrap();
2061 let signature = source
2062 .descendants()
2063 .find(|node| node.tag_name().name() == "Signature")
2064 .unwrap();
2065 let encoded = base64::engine::general_purpose::STANDARD.encode(b"<payload>ok</payload>");
2066 let transforms = vec![
2067 Transform::Base64Decode,
2068 Transform::XPath(XPathExpression::new("true()")),
2069 Transform::Enveloped,
2070 ];
2071
2072 let output = execute_transforms(
2073 signature,
2074 TransformData::Binary(encoded.into()),
2075 &transforms,
2076 )
2077 .unwrap();
2078
2079 assert_eq!(output, b"<payload>ok</payload>");
2080 }
2081
2082 #[test]
2083 fn pipeline_no_transforms_applies_default_c14n() {
2084 let xml = r#"<root b="2" a="1"><child/></root>"#;
2086 let doc = Document::parse(xml).unwrap();
2087
2088 let initial =
2089 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
2090 let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
2091
2092 let output = String::from_utf8(result).unwrap();
2093 assert_eq!(output, r#"<root a="1" b="2"><child></child></root>"#);
2094 }
2095
2096 #[test]
2097 fn pipeline_binary_passthrough() {
2098 let xml = "<root/>";
2101 let doc = Document::parse(xml).unwrap();
2102
2103 let initial = TransformData::Binary(b"raw bytes".to_vec());
2104 let result = execute_transforms(doc.root_element(), initial, &[]).unwrap();
2105
2106 assert_eq!(result, b"raw bytes");
2107 }
2108
2109 #[test]
2112 fn enveloped_only_excludes_own_signature() {
2113 let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2116 <data>hello</data>
2117 <ds:Signature Id="sig-other">
2118 <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
2119 </ds:Signature>
2120 <ds:Signature Id="sig-target">
2121 <ds:SignedInfo><ds:Reference URI=""/></ds:SignedInfo>
2122 </ds:Signature>
2123 </root>"#;
2124 let doc = Document::parse(xml).unwrap();
2125
2126 let sig_node = doc
2128 .descendants()
2129 .find(|n| n.is_element() && n.attribute("Id") == Some("sig-target"))
2130 .unwrap();
2131
2132 let node_set = NodeSet::entire_document_without_comments(&doc).unwrap();
2133 let data = TransformData::NodeSet(node_set);
2134
2135 let result = apply_transform(sig_node, &Transform::Enveloped, data).unwrap();
2136 let node_set = result.into_node_set().unwrap();
2137
2138 let sig_other = doc
2140 .descendants()
2141 .find(|n| n.is_element() && n.attribute("Id") == Some("sig-other"))
2142 .unwrap();
2143 assert!(
2144 node_set.contains(sig_other),
2145 "other Signature elements should NOT be excluded"
2146 );
2147
2148 assert!(
2150 !node_set.contains(sig_node),
2151 "the specific Signature being verified should be excluded"
2152 );
2153 }
2154
2155 #[test]
2158 fn parse_transforms_enveloped_and_exc_c14n() {
2159 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2160 <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
2161 <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2162 </Transforms>"#;
2163 let doc = Document::parse(xml).unwrap();
2164 let transforms_node = doc.root_element();
2165
2166 let chain = parse_transforms(transforms_node).unwrap();
2167 assert_eq!(chain.len(), 2);
2168 assert!(matches!(chain[0], Transform::Enveloped));
2169 assert!(matches!(chain[1], Transform::C14n(_)));
2170 }
2171
2172 #[test]
2173 fn parse_transforms_rejects_unbounded_chain() {
2174 let entries = format!(r#"<Transform Algorithm="{BASE64_TRANSFORM_URI}"/>"#).repeat(65);
2177 let xml = format!(r#"<Transforms xmlns="{XMLDSIG_NS}">{entries}</Transforms>"#);
2178 let doc = Document::parse(&xml).unwrap();
2179
2180 assert!(matches!(
2181 parse_transforms(doc.root_element()),
2182 Err(TransformError::TooManyTransforms {
2183 max: MAX_TRANSFORMS_PER_REFERENCE
2184 })
2185 ));
2186 }
2187
2188 #[test]
2189 fn parse_transforms_accepts_parameterless_base64() {
2190 let xml = format!(
2191 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{BASE64_TRANSFORM_URI}">
2192 </Transform></Transforms>"#
2193 );
2194 let doc = Document::parse(&xml).unwrap();
2195
2196 let chain = parse_transforms(doc.root_element()).unwrap();
2197
2198 assert_eq!(chain.len(), 1);
2199 assert!(matches!(chain[0], Transform::Base64Decode));
2200 }
2201
2202 #[test]
2203 fn parse_transforms_rejects_base64_parameters() {
2204 for parameter in ["<Parameter/>", "unexpected", "\u{00A0}"] {
2205 let xml = format!(
2206 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{BASE64_TRANSFORM_URI}">{parameter}</Transform></Transforms>"#
2207 );
2208 let doc = Document::parse(&xml).unwrap();
2209
2210 let result = parse_transforms(doc.root_element());
2211
2212 assert!(matches!(
2213 result,
2214 Err(TransformError::UnsupportedTransform(_))
2215 ));
2216 }
2217 }
2218
2219 #[test]
2220 fn parse_transforms_rejects_non_xpath_boundary_whitespace() {
2221 let xml = format!(
2224 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><XPath> true()</XPath></Transform></Transforms>"#
2225 );
2226 let doc = Document::parse(&xml).unwrap();
2227
2228 let result = parse_transforms(doc.root_element());
2229
2230 assert!(matches!(result, Err(TransformError::XPath(_))));
2231 }
2232
2233 #[test]
2234 fn parse_transforms_with_inclusive_prefixes() {
2235 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
2236 xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
2237 <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
2238 <ec:InclusiveNamespaces PrefixList="ds saml #default"/>
2239 </Transform>
2240 </Transforms>"#;
2241 let doc = Document::parse(xml).unwrap();
2242 let transforms_node = doc.root_element();
2243
2244 let chain = parse_transforms(transforms_node).unwrap();
2245 assert_eq!(chain.len(), 1);
2246 match &chain[0] {
2247 Transform::C14n(algo) => {
2248 assert!(algo.inclusive_prefixes().contains("ds"));
2249 assert!(algo.inclusive_prefixes().contains("saml"));
2250 assert!(algo.inclusive_prefixes().contains("")); }
2252 other => panic!("expected C14n, got: {other:?}"),
2253 }
2254 }
2255
2256 #[test]
2257 fn parse_transforms_ignores_wrong_ns_inclusive_namespaces() {
2258 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2261 <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
2262 <InclusiveNamespaces xmlns="http://example.com/fake"
2263 PrefixList="attacker-controlled"/>
2264 </Transform>
2265 </Transforms>"#;
2266 let doc = Document::parse(xml).unwrap();
2267
2268 let chain = parse_transforms(doc.root_element()).unwrap();
2269 assert_eq!(chain.len(), 1);
2270 match &chain[0] {
2271 Transform::C14n(algo) => {
2272 assert!(
2274 algo.inclusive_prefixes().is_empty(),
2275 "should ignore InclusiveNamespaces in wrong namespace"
2276 );
2277 }
2278 other => panic!("expected C14n, got: {other:?}"),
2279 }
2280 }
2281
2282 #[test]
2283 fn parse_transforms_missing_prefix_list_is_error() {
2284 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"
2287 xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#">
2288 <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
2289 <ec:InclusiveNamespaces/>
2290 </Transform>
2291 </Transforms>"#;
2292 let doc = Document::parse(xml).unwrap();
2293
2294 let result = parse_transforms(doc.root_element());
2295 assert!(result.is_err());
2296 assert!(matches!(
2297 result.unwrap_err(),
2298 TransformError::UnsupportedTransform(_)
2299 ));
2300 }
2301
2302 #[test]
2303 fn parse_transforms_unsupported_algorithm() {
2304 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2305 <Transform Algorithm="http://example.com/unknown"/>
2306 </Transforms>"#;
2307 let doc = Document::parse(xml).unwrap();
2308
2309 let result = parse_transforms(doc.root_element());
2310 assert!(result.is_err());
2311 assert!(matches!(
2312 result.unwrap_err(),
2313 TransformError::UnsupportedTransform(_)
2314 ));
2315 }
2316
2317 #[test]
2318 fn parse_transforms_missing_algorithm() {
2319 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2320 <Transform/>
2321 </Transforms>"#;
2322 let doc = Document::parse(xml).unwrap();
2323
2324 let result = parse_transforms(doc.root_element());
2325 assert!(result.is_err());
2326 assert!(matches!(
2327 result.unwrap_err(),
2328 TransformError::UnsupportedTransform(_)
2329 ));
2330 }
2331
2332 #[test]
2333 fn parse_transforms_empty() {
2334 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#"/>"#;
2335 let doc = Document::parse(xml).unwrap();
2336
2337 let chain = parse_transforms(doc.root_element()).unwrap();
2338 assert!(chain.is_empty());
2339 }
2340
2341 #[test]
2342 fn parse_transforms_accepts_enveloped_compat_xpath() {
2343 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2344 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2345 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2346 not(ancestor-or-self::dsig:Signature)
2347 </XPath>
2348 </Transform>
2349 </Transforms>"#;
2350 let doc = Document::parse(xml).unwrap();
2351
2352 let chain = parse_transforms(doc.root_element()).unwrap();
2353 assert_eq!(chain.len(), 1);
2354 assert!(matches!(chain[0], Transform::XpathExcludeAllSignatures));
2355 }
2356
2357 #[test]
2358 fn parse_transforms_accepts_general_xpath_expressions() {
2359 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2362 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2363 <XPath>self::node()</XPath>
2364 </Transform>
2365 </Transforms>"#;
2366 let doc = Document::parse(xml).unwrap();
2367
2368 let result = parse_transforms(doc.root_element()).unwrap();
2369 assert!(matches!(result.as_slice(), [Transform::XPath(_)]));
2370 }
2371
2372 #[test]
2373 fn parse_xpath_transform_ignores_comments_and_processing_instructions() {
2374 let xml = format!(
2377 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><!-- before --><?probe value?><XPath>true()</XPath><!-- after --><?done?></Transform></Transforms>"#
2378 );
2379 let doc = Document::parse(&xml).unwrap();
2380
2381 let transforms = parse_transforms(doc.root_element()).unwrap();
2382
2383 assert!(matches!(transforms.as_slice(), [Transform::XPath(_)]));
2384 }
2385
2386 #[test]
2387 fn parse_filter2_transform_ignores_comments_and_processing_instructions() {
2388 let xml = format!(
2391 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}"><!-- before --><?probe value?><XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">/root</XPath><!-- after --><?done?></Transform></Transforms>"#
2392 );
2393 let doc = Document::parse(&xml).unwrap();
2394
2395 let transforms = parse_transforms(doc.root_element()).unwrap();
2396
2397 assert!(matches!(
2398 transforms.as_slice(),
2399 [Transform::XPathFilter2(filters)] if filters.len() == 1
2400 ));
2401 }
2402
2403 #[test]
2404 fn parse_transforms_bounds_raw_xpath_parameter_text() {
2405 let padding = " ".repeat(MAX_XPATH_EXPRESSION_BYTES);
2408 let xml = format!(
2409 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_TRANSFORM_URI}"><XPath>{padding}true()</XPath></Transform></Transforms>"#
2410 );
2411 let doc = Document::parse(&xml).unwrap();
2412
2413 let error = parse_transforms(doc.root_element())
2414 .expect_err("raw XPath parameter text must obey the expression bound");
2415
2416 assert!(matches!(error, TransformError::XPath(_)));
2417 assert!(error.to_string().contains("exceeds"));
2418 }
2419
2420 #[test]
2421 fn parse_transforms_bounds_cumulative_xpath_namespace_storage() {
2422 let declarations = (0..32)
2425 .map(|index| {
2426 format!(
2427 "xmlns:n{index}=\"urn:namespace:{index}:{}\"",
2428 "x".repeat(64)
2429 )
2430 })
2431 .collect::<Vec<_>>()
2432 .join(" ");
2433 let filters = (0..MAX_XPATH_FILTERS)
2434 .map(|_| {
2435 format!(
2436 r#"<XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">true()</XPath>"#
2437 )
2438 })
2439 .collect::<String>();
2440 let xml = format!(
2441 r#"<Transforms xmlns="{XMLDSIG_NS}" {declarations}><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{filters}</Transform></Transforms>"#
2442 );
2443 let doc = Document::parse(&xml).unwrap();
2444
2445 let error = parse_transforms(doc.root_element())
2446 .expect_err("cumulative XPath namespace storage must be bounded");
2447
2448 assert!(error.to_string().contains("namespace binding budget"));
2449 }
2450
2451 #[test]
2452 fn parse_transforms_rejects_xpath_in_wrong_namespace() {
2453 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2454 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2455 <foo:XPath xmlns:foo="http://example.com/ns">
2456 not(ancestor-or-self::dsig:Signature)
2457 </foo:XPath>
2458 </Transform>
2459 </Transforms>"#;
2460 let doc = Document::parse(xml).unwrap();
2461
2462 let result = parse_transforms(doc.root_element());
2463 assert!(result.is_err());
2464 assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
2465 }
2466
2467 #[test]
2468 fn parse_transforms_preserves_nonstandard_prefix_bindings() {
2469 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2473 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2474 <XPath xmlns:dsig="http://example.com/not-xmldsig">
2475 not(ancestor-or-self::dsig:Signature)
2476 </XPath>
2477 </Transform>
2478 </Transforms>"#;
2479 let doc = Document::parse(xml).unwrap();
2480
2481 let result = parse_transforms(doc.root_element()).unwrap();
2482 let [Transform::XPath(xpath)] = result.as_slice() else {
2483 panic!("expected general XPath transform");
2484 };
2485 assert_eq!(
2486 xpath.namespaces().get("dsig").map(String::as_str),
2487 Some("http://example.com/not-xmldsig")
2488 );
2489 }
2490
2491 #[test]
2492 fn parse_transforms_rejects_xpath_with_internal_whitespace_mutation() {
2493 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2494 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2495 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2496 not(ancestor-or-self::dsig:Signa ture)
2497 </XPath>
2498 </Transform>
2499 </Transforms>"#;
2500 let doc = Document::parse(xml).unwrap();
2501
2502 let result = parse_transforms(doc.root_element());
2503 assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
2504 }
2505
2506 #[test]
2507 fn parse_transforms_rejects_multiple_xpath_children() {
2508 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2509 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2510 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2511 not(ancestor-or-self::dsig:Signature)
2512 </XPath>
2513 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2514 not(ancestor-or-self::dsig:Signature)
2515 </XPath>
2516 </Transform>
2517 </Transforms>"#;
2518 let doc = Document::parse(xml).unwrap();
2519
2520 let result = parse_transforms(doc.root_element());
2521 assert!(result.is_err());
2522 assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
2523 }
2524
2525 #[test]
2526 fn parse_transforms_rejects_non_xpath_element_children() {
2527 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2528 <Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116">
2529 <XPath xmlns:dsig="http://www.w3.org/2000/09/xmldsig#">
2530 not(ancestor-or-self::dsig:Signature)
2531 </XPath>
2532 <Extra/>
2533 </Transform>
2534 </Transforms>"#;
2535 let doc = Document::parse(xml).unwrap();
2536
2537 let result = parse_transforms(doc.root_element());
2538 assert!(result.is_err());
2539 assert!(matches!(result.unwrap_err(), TransformError::XPath(_)));
2540 }
2541
2542 #[test]
2543 fn parse_transforms_rejects_malformed_xpath_filter2_parameters() {
2544 for parameter in [
2548 r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2">//Data</XPath>"#,
2549 r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="exclude">//Data</XPath>"#,
2550 r#"<XPath xmlns="urn:wrong" Filter="intersect">//Data</XPath>"#,
2551 r#"<XPath xmlns="http://www.w3.org/2002/06/xmldsig-filter2" Filter="intersect" Extra="value">//Data</XPath>"#,
2552 ] {
2553 let xml = format!(
2554 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{parameter}</Transform></Transforms>"#
2555 );
2556 let doc = Document::parse(&xml).unwrap();
2557
2558 let result = parse_transforms(doc.root_element());
2559
2560 assert!(matches!(result, Err(TransformError::XPath(_))));
2561 }
2562 }
2563
2564 #[test]
2565 fn parse_transforms_rejects_empty_xpath_filter2_sequence() {
2566 let xml = format!(
2569 r#"<Transforms xmlns="{XMLDSIG_NS}"><Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}"/></Transforms>"#
2570 );
2571 let doc = Document::parse(&xml).unwrap();
2572
2573 let result = parse_transforms(doc.root_element());
2574
2575 assert!(matches!(result, Err(TransformError::XPath(_))));
2576 }
2577
2578 #[test]
2579 fn parse_transform_chain_hashes_xpath_document_once() {
2580 let filters = format!(
2583 r#"<XPath xmlns="{XPATH_FILTER2_TRANSFORM_URI}" Filter="intersect">true()</XPath>"#
2584 )
2585 .repeat(MAX_XPATH_FILTERS);
2586 let transform = format!(
2587 r#"<Transform Algorithm="{XPATH_FILTER2_TRANSFORM_URI}">{filters}</Transform>"#
2588 );
2589 let xml =
2590 format!(r#"<Transforms xmlns="{XMLDSIG_NS}">{transform}{transform}</Transforms>"#);
2591 let document = Document::parse(&xml).unwrap();
2592
2593 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
2594 let transforms = parse_transforms(document.root_element()).unwrap();
2595 let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
2596
2597 assert_eq!(transforms.len(), 2);
2598 assert!(transforms.iter().all(
2599 |transform| matches!(transform, Transform::XPathFilter2(filters) if filters.len() == MAX_XPATH_FILTERS)
2600 ));
2601 assert_eq!(
2602 computations, 1,
2603 "one parsed transform chain must hash its source document once"
2604 );
2605 }
2606
2607 #[test]
2608 fn xpath_compat_excludes_other_signature_subtrees_too() {
2609 let xml = r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2610 <payload>keep-me</payload>
2611 <ds:Signature Id="sig-1">
2612 <ds:SignedInfo/>
2613 <ds:SignatureValue>one</ds:SignatureValue>
2614 </ds:Signature>
2615 <ds:Signature Id="sig-2">
2616 <ds:SignedInfo/>
2617 <ds:SignatureValue>two</ds:SignatureValue>
2618 </ds:Signature>
2619 </root>"#;
2620 let doc = Document::parse(xml).unwrap();
2621 let signature_nodes: Vec<_> = doc
2622 .descendants()
2623 .filter(|node| {
2624 node.is_element()
2625 && node.tag_name().name() == "Signature"
2626 && node.tag_name().namespace() == Some(XMLDSIG_NS)
2627 })
2628 .collect();
2629 let sig_node = signature_nodes[0];
2630
2631 let enveloped = execute_transforms(
2632 sig_node,
2633 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap()),
2634 &[
2635 Transform::Enveloped,
2636 Transform::C14n(C14nAlgorithm::new(
2637 crate::c14n::C14nMode::Inclusive1_0,
2638 false,
2639 )),
2640 ],
2641 )
2642 .unwrap();
2643 let xpath_compat = execute_transforms(
2644 sig_node,
2645 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap()),
2646 &[
2647 Transform::XpathExcludeAllSignatures,
2648 Transform::C14n(C14nAlgorithm::new(
2649 crate::c14n::C14nMode::Inclusive1_0,
2650 false,
2651 )),
2652 ],
2653 )
2654 .unwrap();
2655
2656 let enveloped = String::from_utf8(enveloped).unwrap();
2657 let xpath_compat = String::from_utf8(xpath_compat).unwrap();
2658
2659 assert!(enveloped.contains("sig-2"));
2660 assert!(!xpath_compat.contains("sig-1"));
2661 assert!(!xpath_compat.contains("sig-2"));
2662 assert!(xpath_compat.contains("keep-me"));
2663 }
2664
2665 #[test]
2666 fn parse_transforms_inclusive_c14n_variants() {
2667 let xml = r#"<Transforms xmlns="http://www.w3.org/2000/09/xmldsig#">
2668 <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315"/>
2669 <Transform Algorithm="http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments"/>
2670 <Transform Algorithm="http://www.w3.org/2006/12/xml-c14n11"/>
2671 </Transforms>"#;
2672 let doc = Document::parse(xml).unwrap();
2673
2674 let chain = parse_transforms(doc.root_element()).unwrap();
2675 assert_eq!(chain.len(), 3);
2676 for t in &chain {
2678 assert!(matches!(t, Transform::C14n(_)));
2679 }
2680 }
2681
2682 #[test]
2683 fn parsed_xpath_rejects_node_id_collision_from_another_document() {
2684 let source = Document::parse(
2688 r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1</ds:XPath></ds:Transform></ds:Transforms></root>"#,
2689 )
2690 .unwrap();
2691 let transforms_node = source
2692 .descendants()
2693 .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
2694 .unwrap();
2695 let transforms = parse_transforms(transforms_node).unwrap();
2696
2697 let target = Document::parse(
2698 "<root><container><parameter><unrelated/></parameter></container></root>",
2699 )
2700 .unwrap();
2701 let error = execute_transforms(
2702 target.root_element(),
2703 TransformData::NodeSet(NodeSet::entire_document_without_comments(&target).unwrap()),
2704 &transforms,
2705 )
2706 .expect_err("parsed here() provenance must reject another XML document");
2707
2708 assert!(
2709 matches!(error, TransformError::XPath(ref message) if message.contains("same XML document"))
2710 );
2711 }
2712
2713 #[test]
2714 fn transform_chain_computes_document_identity_once() {
2715 let document = Document::parse(
2718 r#"<root xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:Signature><ds:SignedInfo><ds:Reference URI=""><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1 or true()</ds:XPath></ds:Transform><ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xpath-19991116"><ds:XPath>count(. | here()) = 1 or true()</ds:XPath></ds:Transform></ds:Transforms></ds:Reference></ds:SignedInfo></ds:Signature></root>"#,
2719 )
2720 .unwrap();
2721 let transforms_node = document
2722 .descendants()
2723 .find(|node| node.has_tag_name((XMLDSIG_NS, "Transforms")))
2724 .unwrap();
2725 let transforms = parse_transforms(transforms_node).unwrap();
2726 let signature = document
2727 .descendants()
2728 .find(|node| node.has_tag_name((XMLDSIG_NS, "Signature")))
2729 .unwrap();
2730 let initial = NodeSet::entire_document_without_comments(&document)
2731 .map(TransformData::NodeSet)
2732 .unwrap();
2733
2734 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
2735 execute_transforms(signature, initial, &transforms).unwrap();
2736 let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
2737
2738 assert_eq!(
2739 computations, 1,
2740 "one live document must be hashed once per chain"
2741 );
2742 }
2743
2744 #[test]
2745 fn transform_chain_state_keys_identity_by_document() {
2746 let first_document = Document::parse("<first/>").unwrap();
2749 let second_document = Document::parse("<second/>").unwrap();
2750 let state = TransformChainState::default();
2751
2752 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
2753 let first_identity = state.xpath_document_identity(&first_document);
2754 let second_identity = state.xpath_document_identity(&second_document);
2755 let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
2756
2757 assert_ne!(first_identity, second_identity);
2758 assert_eq!(
2759 computations, 2,
2760 "each distinct live document must receive its own cached identity"
2761 );
2762 }
2763
2764 #[test]
2765 fn template_xpath_skips_document_identity_hash() {
2766 let document = Document::parse("<root><value/></root>").unwrap();
2769 let transforms = [
2770 Transform::XPath(XPathExpression::new("true()")),
2771 Transform::XPath(XPathExpression::new("true()")),
2772 ];
2773 let initial = NodeSet::entire_document_without_comments(&document)
2774 .map(TransformData::NodeSet)
2775 .unwrap();
2776
2777 XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(|count| count.set(0));
2778 execute_transforms(document.root_element(), initial, &transforms).unwrap();
2779 let computations = XPATH_DOCUMENT_IDENTITY_COMPUTATIONS.with(Cell::get);
2780
2781 assert_eq!(
2782 computations, 0,
2783 "XPath without parsed here() provenance must not hash XML"
2784 );
2785 }
2786
2787 #[test]
2790 fn saml_enveloped_signature_full_pipeline() {
2791 let xml = r#"<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
2793 xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
2794 ID="_resp1">
2795 <saml:Assertion ID="_assert1">
2796 <saml:Subject>user@example.com</saml:Subject>
2797 </saml:Assertion>
2798 <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
2799 <ds:SignedInfo>
2800 <ds:Reference URI="">
2801 <ds:Transforms>
2802 <ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/>
2803 <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
2804 </ds:Transforms>
2805 </ds:Reference>
2806 </ds:SignedInfo>
2807 <ds:SignatureValue>fakesig==</ds:SignatureValue>
2808 </ds:Signature>
2809 </samlp:Response>"#;
2810 let doc = Document::parse(xml).unwrap();
2811
2812 let sig_node = doc
2814 .descendants()
2815 .find(|n| n.is_element() && n.tag_name().name() == "Signature")
2816 .unwrap();
2817
2818 let reference = doc
2820 .descendants()
2821 .find(|n| n.is_element() && n.tag_name().name() == "Reference")
2822 .unwrap();
2823 let transforms_elem = reference
2824 .children()
2825 .find(|n| n.is_element() && n.tag_name().name() == "Transforms")
2826 .unwrap();
2827 let transforms = parse_transforms(transforms_elem).unwrap();
2828 assert_eq!(transforms.len(), 2);
2829
2830 let initial =
2832 TransformData::NodeSet(NodeSet::entire_document_without_comments(&doc).unwrap());
2833 let result = execute_transforms(sig_node, initial, &transforms).unwrap();
2834
2835 let output = String::from_utf8(result).unwrap();
2836
2837 assert!(!output.contains("Signature"), "Signature should be removed");
2839 assert!(
2840 !output.contains("SignedInfo"),
2841 "SignedInfo should be removed"
2842 );
2843 assert!(
2844 !output.contains("SignatureValue"),
2845 "SignatureValue should be removed"
2846 );
2847 assert!(
2848 !output.contains("fakesig"),
2849 "signature value should be removed"
2850 );
2851
2852 assert!(output.contains("samlp:Response"));
2854 assert!(output.contains("saml:Assertion"));
2855 assert!(output.contains("user@example.com"));
2856 }
2857}