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