main
1# frozen_string_literal: true
2
3module Saml
4 module Kit
5 # This class is responsible for deciding whether an xml digital signature
6 # actually covers the element it is attached to.
7 #
8 # A cryptographically intact signature only proves that *some* element in
9 # the document is unmodified. xmldsig resolves a Reference by searching the
10 # whole document for a matching ID, so without this check an attacker can
11 # copy a valid signature onto a forged element and hide the element it
12 # really covers somewhere the schema still permits, e.g. saml:Advice or
13 # ds:Object.
14 class SignatureReference
15 # The id_attr handed to Xmldsig::Signature, which expands it into
16 # "//*[@ID=$uri or @Id=$uri]". Both resolvers must agree on this or the
17 # element we check is not the element that was digested.
18 ID_ATTR = 'ID=$uri or @Id'
19 XPATH = './ds:SignedInfo/ds:Reference'
20
21 def initialize(node)
22 @node = node
23 end
24
25 # Returns true when some Reference resolves to the element the signature
26 # hangs off, and resolves to nothing else.
27 #
28 # Every Reference lives inside ds:SignedInfo, which ds:SignatureValue
29 # covers, so an attacker replaying a signature cannot add a Reference or
30 # retarget an existing one. Finding our element among them is therefore
31 # enough. SAML 2.0 core 5.4.2 permits only one, but documents carrying
32 # more exist, and each extra Reference only widens what the signature
33 # already commits to.
34 def bound?
35 return @bound if defined?(@bound)
36
37 @bound = binds_parent_element?
38 end
39
40 private
41
42 attr_reader :node
43
44 def binds_parent_element?
45 return false if node.nil? || signed.nil?
46
47 references.any? { |reference| covers_signed_element?(reference) }
48 end
49
50 def covers_signed_element?(reference)
51 uri = reference.attribute('URI').try(:value).to_s
52 # An empty URI digests the whole document, so it covers every element in
53 # it and leaves nowhere to hide an original.
54 return true if uri.empty?
55
56 id.present? && uri == "##{id}" && resolves_to_signed_element?
57 end
58
59 # `bound?` memoizes the one answer this class exists to give, so only the
60 # XPath search below is worth caching; the rest are O(1) node lookups.
61 def signed
62 node.parent
63 end
64
65 def references
66 @references ||= node.search(XPATH, Saml::Kit::Document::NAMESPACES)
67 end
68
69 def id
70 signed.attribute('ID').try(:value) || signed.attribute('Id').try(:value)
71 end
72
73 # Duplicate IDs are only rejected when schema validation runs, and an
74 # XPath lookup silently returns the first match, so require exactly one.
75 def resolves_to_signed_element?
76 matches = node.document.xpath(
77 "//*[@#{ID_ATTR}=$uri]",
78 Saml::Kit::Document::NAMESPACES,
79 'uri' => id
80 )
81 matches.count == 1 && matches.first == signed
82 end
83 end
84 end
85end