saml-kit 1.4.1 → 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: ce0f7fc307eddfbefa35cbf90a0efba6d38782e51c20e7aa16144e774d6a97d8
4
- data.tar.gz: fd22e8f11f3b57d8dd7a305367362874eb5073d0b5ee1ffb0ae490a5a5a8fe43
3
+ metadata.gz: 6758626dbf26ac6ea50535978aead6d9046a6dd31662cff9eb962154a682380e
4
+ data.tar.gz: 8fd7df975fa700f8d969f77c2fff0f59e9829ef3bde7ab6f41a6b5a94cb208d7
5
5
  SHA512:
6
- metadata.gz: 58984c2369ed1dc697d634cdde413d3f780de83759bcefd5b7354d1bbbf7ec4cc477ef6821d67915a00bc6b92c8477c2e4dfa991d83727fb1525bcfeb4b114c5
7
- data.tar.gz: c7c3b3bb94cff25d9d9bb17b2d0e66600763cd302053f0182a261cba4a704af561e6f85167dd330ef2f4deef5c7e06e8eda19858749889c9f8859f24d03798c1
6
+ metadata.gz: 1e80fc106bbc6e06e167c0e5b6ce7b7f09d3bf7e471558533ec2a061089dc0367d60da5a393436a9feb3e8f5b12745193d7652c29f5d9cc9dae9ecd151a42a19
7
+ data.tar.gz: a3a39fcbff995f9ac1dceca502412efd165690fdd66b91d093f423d8cc1573486c95655b1f958746f98c682c48eba69357a8b058fef53d216e770c7e5b9d8620
data/CHANGELOG.md CHANGED
@@ -1,4 +1,4 @@
1
- Version 1.4.1
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,38 @@ 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
+
11
43
  ## [1.4.1] - 2026-08-14
12
44
  ### Changed
13
45
  - fix: decode encoded signature
@@ -110,7 +142,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
110
142
  ### Removed
111
143
  - Removed optional SessionNotOnOrAfter attribute from AuthnStatement.
112
144
 
113
- [Unreleased]: https://github.com/xlgmokha/saml-kit/compare/v1.4.1...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
114
147
  [1.4.1]: https://github.com/xlgmokha/saml-kit/compare/v1.4.0...v1.4.1
115
148
  [1.4.0]: https://github.com/xlgmokha/saml-kit/compare/v1.3.0...v1.4.0
116
149
  [1.3.0]: https://github.com/xlgmokha/saml-kit/compare/v1.2.0...v1.3.0
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- saml-kit (1.4.1)
4
+ saml-kit (1.5.0)
5
5
  activemodel (>= 5.1)
6
6
  base64 (~> 0.1)
7
7
  cgi (~> 0.1)
@@ -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?
@@ -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.1'
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'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: saml-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -413,6 +413,7 @@ files:
413
413
  - lib/saml/kit/rspec/have_xpath.rb
414
414
  - lib/saml/kit/service_provider_metadata.rb
415
415
  - lib/saml/kit/signature.rb
416
+ - lib/saml/kit/signature_reference.rb
416
417
  - lib/saml/kit/version.rb
417
418
  - lib/saml/kit/xsd/MetadataExchange.xsd
418
419
  - lib/saml/kit/xsd/oasis-200401-wss-wssecurity-secext-1.0.xsd