saml-kit 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf6c86073d43c4c6b2f55aa091d71e1fb9e58d823e1e25936ba598c9db8c2304
4
- data.tar.gz: 1896cf94e32bd938a3b4c3ae96c5e5b88a1433c8988866fa6d12e64a035812d5
3
+ metadata.gz: 6758626dbf26ac6ea50535978aead6d9046a6dd31662cff9eb962154a682380e
4
+ data.tar.gz: 8fd7df975fa700f8d969f77c2fff0f59e9829ef3bde7ab6f41a6b5a94cb208d7
5
5
  SHA512:
6
- metadata.gz: 1287fa37a0daaf27d591dcdfd03241897ef7b8773c4d1e9a4cab96c9940b5b475e54691062e76916e44e030c494aa34f32fa5a3496d295366f8a16b01e5333c2
7
- data.tar.gz: 10b7edcc0ed623d9a84fbe03926306e0d224eb20fde850ca2900086882f518b77029952f2f35ca33a33b80412513c0669c6a1296d72eaecb12f9bef38afe9e80
6
+ metadata.gz: 1e80fc106bbc6e06e167c0e5b6ce7b7f09d3bf7e471558533ec2a061089dc0367d60da5a393436a9feb3e8f5b12745193d7652c29f5d9cc9dae9ecd151a42a19
7
+ data.tar.gz: a3a39fcbff995f9ac1dceca502412efd165690fdd66b91d093f423d8cc1573486c95655b1f958746f98c682c48eba69357a8b058fef53d216e770c7e5b9d8620
data/CHANGELOG.md CHANGED
@@ -1,4 +1,4 @@
1
- Version 1.4.0
1
+ Version 1.5.0
2
2
 
3
3
  # Changelog
4
4
  All notable changes to this project will be documented in this file.
@@ -8,6 +8,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] - 2026-08-17
12
+ ### Security
13
+ - Reject a `Response` or `Assertion` that carries no signature. A registered
14
+ issuer was previously enough to make an unsigned document valid, so anyone
15
+ who could reach the assertion consumer service could forge an assertion for
16
+ any user. (GHSA-hx7c-98jm-7r55)
17
+ - Require an xml signature to reference the element it is attached to. A
18
+ signature was verified without checking which element it covered, so a valid
19
+ signature could be copied onto a forged element with the element it really
20
+ covered hidden in `saml:Advice` or `ds:Object`. (GHSA-q726-45rx-qvrw)
21
+
22
+ ### Changed
23
+ This release rejects documents that 1.4.1 accepted. Each case below was
24
+ exploitable or is a side effect of closing one that was.
25
+
26
+ - An unsigned `Response` or `Assertion` is now invalid, even when its issuer is
27
+ registered. Set `configuration.signature_required = false` to restore the old
28
+ behaviour while an identity provider is being fixed; doing so accepts forged
29
+ assertions. Signing only the `Assertion` is still accepted, as is an unsigned
30
+ `AuthnRequest`, `LogoutRequest` or `LogoutResponse`.
31
+ - `Response.build` on a `Configuration` with no signing key pair emits an
32
+ unsigned document, which now fails `valid?`. Call
33
+ `generate_key_pair_for(use: :signing)` or `add_key_pair`.
34
+ - `Assertion.new` with an unsigned assertion as a `String` is now invalid,
35
+ because a bare assertion is the whole document and nothing else vouches for
36
+ it. Passing the same assertion as a node nested in a `Response` is unchanged.
37
+ - A signature whose `ds:Reference` does not resolve to the element it hangs
38
+ off, or resolves to more than one element, adds an error on `:reference`.
39
+
40
+ ### Added
41
+ - `Saml::Kit::Configuration#signature_required`, defaulting to `true`.
42
+
43
+ ## [1.4.1] - 2026-08-14
44
+ ### Changed
45
+ - fix: decode encoded signature
46
+
11
47
  ## [1.4.0] - 2025-03-26
12
48
  ### Added
13
49
  - Add support for converting `Assertion` xml to `Saml::Document`
@@ -106,7 +142,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
106
142
  ### Removed
107
143
  - Removed optional SessionNotOnOrAfter attribute from AuthnStatement.
108
144
 
109
- [Unreleased]: https://github.com/xlgmokha/saml-kit/compare/v1.4.0...HEAD
145
+ [Unreleased]: https://github.com/xlgmokha/saml-kit/compare/v1.5.0...HEAD
146
+ [1.5.0]: https://github.com/xlgmokha/saml-kit/compare/v1.4.1...v1.5.0
147
+ [1.4.1]: https://github.com/xlgmokha/saml-kit/compare/v1.4.0...v1.4.1
110
148
  [1.4.0]: https://github.com/xlgmokha/saml-kit/compare/v1.3.0...v1.4.0
111
149
  [1.3.0]: https://github.com/xlgmokha/saml-kit/compare/v1.2.0...v1.3.0
112
150
  [1.2.0]: https://github.com/xlgmokha/saml-kit/compare/v1.1.0...v1.2.0
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- saml-kit (1.4.0)
4
+ saml-kit (1.5.0)
5
5
  activemodel (>= 5.1)
6
6
  base64 (~> 0.1)
7
7
  cgi (~> 0.1)
@@ -16,66 +16,84 @@ PATH
16
16
  GEM
17
17
  remote: https://rubygems.org/
18
18
  specs:
19
- activemodel (7.2.2.1)
20
- activesupport (= 7.2.2.1)
21
- activesupport (7.2.2.1)
19
+ activemodel (8.1.3.1)
20
+ activesupport (= 8.1.3.1)
21
+ activesupport (8.1.3.1)
22
22
  base64
23
- benchmark (>= 0.3)
24
23
  bigdecimal
25
24
  concurrent-ruby (~> 1.0, >= 1.3.1)
26
25
  connection_pool (>= 2.2.5)
27
26
  drb
28
27
  i18n (>= 1.6, < 2)
28
+ json
29
29
  logger (>= 1.4.2)
30
30
  minitest (>= 5.1)
31
31
  securerandom (>= 0.3)
32
32
  tzinfo (~> 2.0, >= 2.0.5)
33
- addressable (2.8.7)
34
- public_suffix (>= 2.0.2, < 7.0)
33
+ uri (>= 0.13.1)
34
+ addressable (2.9.0)
35
+ public_suffix (>= 2.0.2, < 8.0)
35
36
  ast (2.4.3)
36
- base64 (0.2.0)
37
- benchmark (0.4.0)
37
+ base64 (0.3.0)
38
38
  benchmark-malloc (0.2.0)
39
39
  benchmark-perf (0.6.0)
40
40
  benchmark-trend (0.4.0)
41
- bigdecimal (3.1.9)
41
+ bigdecimal (4.1.2)
42
42
  builder (3.3.0)
43
43
  bundler-audit (0.9.2)
44
44
  bundler (>= 1.2.0, < 3)
45
45
  thor (~> 1.0)
46
- cgi (0.4.2)
47
- concurrent-ruby (1.3.5)
48
- connection_pool (2.5.0)
46
+ cgi (0.5.2)
47
+ concurrent-ruby (1.3.8)
48
+ connection_pool (3.0.2)
49
49
  crack (1.0.0)
50
50
  bigdecimal
51
51
  rexml
52
52
  date (3.4.1)
53
53
  diff-lcs (1.6.1)
54
54
  docile (1.4.1)
55
- drb (2.2.1)
56
- erb (4.0.4)
55
+ drb (2.2.3)
56
+ erb (4.0.4.1)
57
57
  cgi (>= 0.3.3)
58
58
  ffaker (2.24.0)
59
59
  forwardable (1.3.3)
60
60
  hashdiff (1.1.2)
61
- i18n (1.14.7)
61
+ i18n (1.15.2)
62
62
  concurrent-ruby (~> 1.0)
63
63
  io-console (0.8.0)
64
64
  irb (1.15.1)
65
65
  pp (>= 0.6.0)
66
66
  rdoc (>= 4.0.0)
67
67
  reline (>= 0.4.2)
68
- json (2.10.2)
68
+ json (2.21.2)
69
69
  language_server-protocol (3.17.0.4)
70
70
  lint_roller (1.1.0)
71
- logger (1.6.6)
72
- mini_portile2 (2.8.8)
73
- minitest (5.25.5)
71
+ logger (1.7.0)
72
+ mini_portile2 (2.8.9)
73
+ minitest (6.0.6)
74
+ drb (~> 2.0)
75
+ prism (~> 1.5)
74
76
  net-hippie (1.2.0)
75
77
  logger (~> 1.0)
76
- nokogiri (1.18.6)
78
+ nokogiri (1.19.4)
77
79
  mini_portile2 (~> 2.8.2)
78
80
  racc (~> 1.4)
81
+ nokogiri (1.19.4-aarch64-linux-gnu)
82
+ racc (~> 1.4)
83
+ nokogiri (1.19.4-aarch64-linux-musl)
84
+ racc (~> 1.4)
85
+ nokogiri (1.19.4-arm-linux-gnu)
86
+ racc (~> 1.4)
87
+ nokogiri (1.19.4-arm-linux-musl)
88
+ racc (~> 1.4)
89
+ nokogiri (1.19.4-arm64-darwin)
90
+ racc (~> 1.4)
91
+ nokogiri (1.19.4-x86_64-darwin)
92
+ racc (~> 1.4)
93
+ nokogiri (1.19.4-x86_64-linux-gnu)
94
+ racc (~> 1.4)
95
+ nokogiri (1.19.4-x86_64-linux-musl)
96
+ racc (~> 1.4)
79
97
  parallel (1.26.3)
80
98
  parser (3.3.7.3)
81
99
  ast (~> 2.4.1)
@@ -83,11 +101,11 @@ GEM
83
101
  pp (0.6.2)
84
102
  prettyprint
85
103
  prettyprint (0.2.0)
86
- prism (1.4.0)
104
+ prism (1.9.0)
87
105
  psych (5.2.3)
88
106
  date
89
107
  stringio
90
- public_suffix (6.0.1)
108
+ public_suffix (7.0.5)
91
109
  racc (1.8.1)
92
110
  rainbow (3.1.1)
93
111
  rake (13.2.1)
@@ -96,7 +114,7 @@ GEM
96
114
  regexp_parser (2.10.0)
97
115
  reline (0.6.0)
98
116
  io-console (~> 0.5)
99
- rexml (3.4.1)
117
+ rexml (3.4.4)
100
118
  rspec (3.13.0)
101
119
  rspec-core (~> 3.13.0)
102
120
  rspec-expectations (~> 3.13.0)
@@ -141,15 +159,15 @@ GEM
141
159
  simplecov_json_formatter (~> 0.1)
142
160
  simplecov-html (0.13.1)
143
161
  simplecov_json_formatter (0.1.4)
144
- stringio (3.1.6)
162
+ stringio (3.2.0)
145
163
  thor (1.3.2)
146
164
  tilt (2.6.0)
147
165
  tzinfo (2.0.6)
148
166
  concurrent-ruby (~> 1.0)
149
167
  unicode-display_width (3.1.4)
150
168
  unicode-emoji (~> 4.0, >= 4.0.4)
151
- unicode-emoji (4.0.4)
152
- uri (1.0.3)
169
+ unicode-emoji (4.2.0)
170
+ uri (1.1.1)
153
171
  webmock (3.25.1)
154
172
  addressable (>= 2.8.0)
155
173
  crack (>= 0.3.2)
@@ -164,7 +182,15 @@ GEM
164
182
  nokogiri (>= 1.6.8, < 2.0.0)
165
183
 
166
184
  PLATFORMS
185
+ aarch64-linux-gnu
186
+ aarch64-linux-musl
187
+ arm-linux-gnu
188
+ arm-linux-musl
189
+ arm64-darwin
167
190
  ruby
191
+ x86_64-darwin
192
+ x86_64-linux-gnu
193
+ x86_64-linux-musl
168
194
 
169
195
  DEPENDENCIES
170
196
  bundler (~> 2.0)
@@ -89,9 +89,7 @@ module Saml
89
89
  end
90
90
 
91
91
  def decryptable?
92
- return true unless encrypted?
93
-
94
- !@cannot_decrypt
92
+ !encrypted? || !@cannot_decrypt
95
93
  end
96
94
 
97
95
  def to_s
@@ -100,6 +98,12 @@ module Saml
100
98
 
101
99
  private
102
100
 
101
+ # A nested Assertion is covered by the enclosing Response's policy; a
102
+ # bare one is the whole document and has nothing else vouching for it.
103
+ def signature_required_by_type?
104
+ to_nokogiri == to_nokogiri.document.root
105
+ end
106
+
103
107
  def decrypt(decryptor)
104
108
  encrypted_assertion = at_xpath('./xmlenc:EncryptedData')
105
109
  @encrypted = encrypted_assertion.present?
@@ -49,13 +49,24 @@ module Saml
49
49
 
50
50
  return document.signature_verified! if provider.verify(
51
51
  algorithm_for(params[:SigAlg]),
52
- decode(signature),
52
+ decode(signature_from(signature)),
53
53
  canonicalize(params)
54
54
  )
55
55
 
56
56
  raise ArgumentError, 'Invalid Signature'
57
57
  end
58
58
 
59
+ # The signature arrives percent encoded from any conformant peer, but
60
+ # versions of this library before 1.4.1 emitted it raw. A percent sign
61
+ # is not in the base64 alphabet, so its presence distinguishes the two
62
+ # without a guess. Unescaping unconditionally would corrupt a raw
63
+ # signature, because CGI.unescape turns + into a space.
64
+ #
65
+ # @param signature [String] the Signature query parameter.
66
+ def signature_from(signature)
67
+ signature.include?('%') ? unescape(signature) : signature
68
+ end
69
+
59
70
  def canonicalize(params)
60
71
  %i[SAMLRequest SAMLResponse RelayState SigAlg].map do |key|
61
72
  value = params[key]
@@ -21,7 +21,7 @@ module Saml
21
21
  destination = document.destination
22
22
  if configuration.sign?
23
23
  payload = canonicalize(document, relay_state)
24
- "#{destination}?#{payload}&Signature=#{signature_for(payload)}"
24
+ "#{destination}?#{payload}&Signature=#{escape(signature_for(payload))}"
25
25
  else
26
26
  "#{destination}?" + to_query_string(
27
27
  document.query_string_parameter => serialize(document.to_xml),
@@ -11,9 +11,15 @@ module Saml
11
11
  included do
12
12
  validate :must_have_valid_signature, unless: :signature_verified
13
13
  validate :must_be_registered
14
+ validate :must_be_signed, if: :signature_required?
14
15
  validate :must_be_trusted
15
16
  end
16
17
 
18
+ # Returns true when this document must carry a signature to be valid.
19
+ def signature_required?
20
+ configuration.signature_required && signature_required_by_type?
21
+ end
22
+
17
23
  # Returns true when the document has an embedded XML Signature or has
18
24
  # been verified externally.
19
25
  def signed?
@@ -65,9 +71,40 @@ module Saml
65
71
  errors.add(:provider, error_message(:unregistered))
66
72
  end
67
73
 
74
+ # Whether a document of this type is worthless unless signed.
75
+ #
76
+ # A request may legitimately arrive unsigned, which is what
77
+ # `WantAuthnRequestsSigned="false"` means, so the default is false. A
78
+ # document that asserts an identity carries no authenticity guarantee at
79
+ # all when unsigned, so Response and Assertion override this.
80
+ def signature_required_by_type?
81
+ false
82
+ end
83
+
84
+ def must_be_signed
85
+ return unless expected_type?
86
+ return if signature_covers_document?
87
+
88
+ errors.add(:base, error_message(:unsigned))
89
+ end
90
+
91
+ # Whether a signature exists over the content this document asserts.
92
+ # Response overrides this, because a signature on either the Response or
93
+ # the Assertion covers the identity being asserted.
94
+ def signature_covers_document?
95
+ signed?
96
+ end
97
+
98
+ # The trust counterpart of `signature_covers_document?`. Kept separate
99
+ # from the public `trusted?`, which answers the narrower question of
100
+ # whether *this* element's own signature is trusted.
101
+ def signature_trusted?
102
+ trusted?
103
+ end
104
+
68
105
  def must_be_trusted
69
- return if trusted?
70
- return if provider.present? && !signed?
106
+ return if signature_trusted?
107
+ return if !signature_required? && provider.present? && !signed?
71
108
 
72
109
  errors.add(:fingerprint, error_message(:invalid_fingerprint))
73
110
  end
@@ -46,6 +46,13 @@ module Saml
46
46
  attr_accessor :logger
47
47
  # The total allowable clock drift for session timeout validation.
48
48
  attr_accessor :clock_drift
49
+ # Whether a document that asserts an identity must carry a signature.
50
+ #
51
+ # An unsigned Response or Assertion proves nothing about who issued it,
52
+ # so this defaults to true and should stay that way. Set it to false only
53
+ # to keep an identity provider that signs nothing working while it is
54
+ # being fixed, and understand that doing so accepts forged assertions.
55
+ attr_accessor :signature_required
49
56
 
50
57
  def initialize
51
58
  @clock_drift = 30.seconds
@@ -55,6 +62,7 @@ module Saml
55
62
  @registry = DefaultRegistry.new
56
63
  @session_timeout = 3.hours
57
64
  @signature_method = :SHA256
65
+ @signature_required = true
58
66
  yield self if block_given?
59
67
  end
60
68
 
@@ -11,6 +11,7 @@ en:
11
11
  must_contain_single_assertion: "must contain single Assertion."
12
12
  must_match_issuer: "must match entityId."
13
13
  unregistered: "is unregistered."
14
+ unsigned: "must be signed."
14
15
  AuthnRequest:
15
16
  invalid: "must contain AuthnRequest."
16
17
  invalid_fingerprint: "is not registered."
@@ -34,10 +35,12 @@ en:
34
35
  invalid_version: "must be 2.0."
35
36
  unregistered: "must originate from registered identity provider."
36
37
  must_contain_single_assertion: "must contain a single Assertion."
38
+ unsigned: "must be signed."
37
39
  Signature:
38
40
  certificate: "Not valid before %{not_before}. Not valid after %{not_after}."
39
41
  digest_value: "is invalid."
40
42
  empty: "is missing."
43
+ reference: "must reference the element it is attached to."
41
44
  signature: "is invalid."
42
45
  SPSSODescriptor:
43
46
  invalid: "must contain SPSSODescriptor."
@@ -41,10 +41,26 @@ module Saml
41
41
 
42
42
  private
43
43
 
44
+ def signature_required_by_type?
45
+ true
46
+ end
47
+
48
+ # `signed?` and `trusted?` deliberately report only this element's own
49
+ # signature, but signing only the Assertion is a common identity provider
50
+ # configuration, and that signature covers the identity being asserted.
51
+ def signature_covers_document?
52
+ signed? || assertion.signed?
53
+ end
54
+
55
+ def signature_trusted?
56
+ super || (assertion.signed? && assertion.trusted?)
57
+ end
58
+
44
59
  def must_be_valid_assertion
45
60
  assertion.valid?
46
61
  assertion.each_error do |attribute, error|
47
- errors.add(attribute == :base ? :assertion : attribute, error)
62
+ attribute = :assertion if attribute == :base
63
+ errors.add(attribute, error) unless errors.added?(attribute, error)
48
64
  end
49
65
  end
50
66
 
@@ -11,6 +11,7 @@ module Saml
11
11
 
12
12
  validate :validate_signature
13
13
  validate :validate_certificate
14
+ validate :validate_reference
14
15
 
15
16
  attr_reader :name
16
17
 
@@ -20,18 +21,24 @@ module Saml
20
21
  end
21
22
 
22
23
  # Returns the embedded X509 Certificate
24
+ #
25
+ # Memoized with a `defined?` guard rather than `||=` because a signature
26
+ # without KeyInfo legitimately has no certificate, and callers depend on
27
+ # the nil.
23
28
  def certificate
24
- xpath = './ds:KeyInfo/ds:X509Data/ds:X509Certificate'
25
- value = at_xpath(xpath).try(:text)
26
- return if value.nil?
29
+ return @certificate if defined?(@certificate)
27
30
 
28
- ::Xml::Kit::Certificate.new(value, use: :signing)
31
+ value = at_xpath('./ds:KeyInfo/ds:X509Data/ds:X509Certificate').try(:text)
32
+ @certificate =
33
+ value.nil? ? nil : ::Xml::Kit::Certificate.new(value, use: :signing)
29
34
  end
30
35
 
31
36
  # Returns true when the fingerprint of the certificate matches one of
32
37
  # the certificates registered in the metadata.
33
38
  def trusted?(metadata)
34
39
  return false if metadata.nil?
40
+ return false if certificate.nil?
41
+ return false unless reference.bound?
35
42
 
36
43
  metadata.matches?(certificate.fingerprint, use: :signing).present?
37
44
  end
@@ -66,15 +73,9 @@ module Saml
66
73
  end
67
74
 
68
75
  def transforms
69
- xpath = xpath_for([
70
- '.',
71
- 'ds:SignedInfo',
72
- 'ds:Reference',
73
- 'ds:Transforms',
74
- 'ds:Transform',
75
- '@Algorithm',
76
- ])
77
- node.search(xpath, Saml::Kit::Document::NAMESPACES).try(:map, &:value)
76
+ xpath = './ds:SignedInfo/ds:Reference/ds:Transforms/ds:Transform'
77
+ node.search("#{xpath}/@Algorithm", Saml::Kit::Document::NAMESPACES)
78
+ .try(:map, &:value)
78
79
  end
79
80
 
80
81
  # Returns the XML Hash.
@@ -109,6 +110,17 @@ module Saml
109
110
  errors.add(:base, error.message)
110
111
  end
111
112
 
113
+ def validate_reference
114
+ return unless present?
115
+ return if reference.bound?
116
+
117
+ errors.add(:reference, error_message(:reference))
118
+ end
119
+
120
+ def reference
121
+ @reference ||= SignatureReference.new(node)
122
+ end
123
+
112
124
  def validate_certificate(now = Time.now.utc)
113
125
  return unless certificate.present?
114
126
  return if certificate.active?(now)
@@ -128,11 +140,7 @@ module Saml
128
140
  end
129
141
 
130
142
  def dsignature
131
- @dsignature ||= Xmldsig::Signature.new(node, 'ID=$uri or @Id')
132
- end
133
-
134
- def xpath_for(segments)
135
- segments.join('/')
143
+ @dsignature ||= Xmldsig::Signature.new(node, SignatureReference::ID_ATTR)
136
144
  end
137
145
  end
138
146
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module 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
85
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Saml
4
4
  module Kit
5
- VERSION = '1.4.0'
5
+ VERSION = '1.5.0'
6
6
  end
7
7
  end
data/lib/saml/kit.rb CHANGED
@@ -44,6 +44,7 @@ require 'saml/kit/logout_request'
44
44
  require 'saml/kit/metadata'
45
45
  require 'saml/kit/deprecated/metadata'
46
46
  require 'saml/kit/null_assertion'
47
+ require 'saml/kit/signature_reference'
47
48
  require 'saml/kit/organization'
48
49
  require 'saml/kit/parser'
49
50
  require 'saml/kit/composite_metadata'
data/saml-kit.gemspec CHANGED
@@ -14,7 +14,7 @@ Gem::Specification.new do |spec|
14
14
  spec.description = 'A simple toolkit for working with SAML.'
15
15
  spec.homepage = 'https://github.com/xlgmokha/saml-kit'
16
16
  spec.license = 'MIT'
17
- spec.required_ruby_version = '>= 3.1.0'
17
+ spec.required_ruby_version = '>= 3.2.0'
18
18
 
19
19
  spec.files = `git ls-files -z`.split("\x0").reject do |f|
20
20
  (
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saml-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2025-03-26 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: activemodel
@@ -414,6 +413,7 @@ files:
414
413
  - lib/saml/kit/rspec/have_xpath.rb
415
414
  - lib/saml/kit/service_provider_metadata.rb
416
415
  - lib/saml/kit/signature.rb
416
+ - lib/saml/kit/signature_reference.rb
417
417
  - lib/saml/kit/version.rb
418
418
  - lib/saml/kit/xsd/MetadataExchange.xsd
419
419
  - lib/saml/kit/xsd/oasis-200401-wss-wssecurity-secext-1.0.xsd
@@ -448,7 +448,6 @@ licenses:
448
448
  - MIT
449
449
  metadata:
450
450
  yard.run: yri
451
- post_install_message:
452
451
  rdoc_options: []
453
452
  require_paths:
454
453
  - lib
@@ -456,15 +455,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
456
455
  requirements:
457
456
  - - ">="
458
457
  - !ruby/object:Gem::Version
459
- version: 3.1.0
458
+ version: 3.2.0
460
459
  required_rubygems_version: !ruby/object:Gem::Requirement
461
460
  requirements:
462
461
  - - ">="
463
462
  - !ruby/object:Gem::Version
464
463
  version: '0'
465
464
  requirements: []
466
- rubygems_version: 3.3.27
467
- signing_key:
465
+ rubygems_version: 4.0.18
468
466
  specification_version: 4
469
467
  summary: A simple toolkit for working with SAML.
470
468
  test_files: []