main
  1# frozen_string_literal: true
  2
  3# Regression coverage for the signature stripping and signature wrapping
  4# advisories. Each example asserts the behaviour we want, so every example in
  5# this file fails against a vulnerable build.
  6#
  7# Nothing here is stubbed: real key pairs, a real registry, real signatures.
  8# Stubbing the trust path would defeat the purpose.
  9# This file spans Response, Assertion, Signature and Trustable, so there is no
 10# single described class.
 11# rubocop:disable RSpec/DescribeClass
 12RSpec.describe 'Signature verification' do
 13  subject(:response) { Saml::Kit::Response.new(xml, configuration: sp_configuration) }
 14
 15  let(:namespaces) { Saml::Kit::Document::NAMESPACES }
 16  let(:as_xml) { Nokogiri::XML::Node::SaveOptions::AS_XML }
 17
 18  let(:idp_entity_id) { 'https://idp.example.com/metadata' }
 19  let(:sp_entity_id) { 'https://sp.example.com/metadata' }
 20
 21  let(:idp_configuration) do
 22    Saml::Kit::Configuration.new do |config|
 23      config.entity_id = idp_entity_id
 24      config.generate_key_pair_for(use: :signing)
 25    end
 26  end
 27
 28  let(:registry) do
 29    Saml::Kit::DefaultRegistry.new.tap do |x|
 30      x.register(Saml::Kit::IdentityProviderMetadata.new(idp_metadata_xml))
 31    end
 32  end
 33
 34  let(:idp_metadata_xml) do
 35    Saml::Kit::IdentityProviderMetadata.build(configuration: idp_configuration) do |x|
 36      x.entity_id = idp_entity_id
 37      x.add_single_sign_on_service('https://idp.example.com/login', binding: :http_post)
 38    end.to_xml
 39  end
 40
 41  let(:sp_configuration) do
 42    Saml::Kit::Configuration.new do |config|
 43      config.entity_id = sp_entity_id
 44      config.registry = registry
 45    end
 46  end
 47
 48  let(:user) { User.new(attributes: { id: SecureRandom.uuid }) }
 49  let(:request) do
 50    instance_double(
 51      Saml::Kit::AuthenticationRequest,
 52      id: ::Xml::Kit::Id.generate,
 53      issuer: sp_entity_id,
 54      assertion_consumer_service_url: "#{sp_entity_id}/acs",
 55      name_id_format: Saml::Kit::Namespaces::PERSISTENT,
 56      provider: nil,
 57      signed?: true,
 58      trusted?: true
 59    )
 60  end
 61
 62  # A Response signed at both the Response and Assertion level.
 63  let(:signed_xml) do
 64    Saml::Kit::Response.build(user, request, configuration: idp_configuration) do |x|
 65      x.issuer = idp_entity_id
 66      x.embed_signature = true
 67    end.to_xml
 68  end
 69
 70  # A Response signed only at the Response level.
 71  let(:response_signed_xml) do
 72    Saml::Kit::Response.build(user, request, configuration: idp_configuration) do |x|
 73      x.issuer = idp_entity_id
 74      x.embed_signature = true
 75      x.assertion.embed_signature = false
 76    end.to_xml
 77  end
 78
 79  def described_response(xml)
 80    Saml::Kit::Response.new(xml, configuration: sp_configuration)
 81  end
 82
 83  shared_examples 'an accepted Response' do
 84    it 'is valid and reports the real subject' do
 85      expect(response).to be_valid
 86      expect(response.name_id).to eql(user.name_id)
 87    end
 88  end
 89
 90  # Rejecting a forgery is only meaningful if the reference check still binds a
 91  # signature to whichever element the identity provider chose to sign.
 92  shared_examples 'a rejected forgery' do
 93    it 'is invalid' do
 94      expect(response).to be_invalid
 95    end
 96
 97    it 'does not report the forged document as trusted' do
 98      response.valid?
 99      expect(response).not_to be_trusted
100      expect(response.assertion).not_to be_trusted
101    end
102  end
103
104  context 'when signed at both the Response and Assertion level' do
105    let(:xml) { signed_xml }
106
107    it_behaves_like 'an accepted Response'
108  end
109
110  context 'when signed only at the Response level' do
111    let(:xml) { response_signed_xml }
112
113    it_behaves_like 'an accepted Response'
114  end
115
116  # Signing only the Assertion is the default for several identity providers,
117  # so requiring a signature must not reject it.
118  context 'when signed only at the Assertion level' do
119    let(:xml) do
120      document = Nokogiri::XML(signed_xml)
121      document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
122      document.to_xml(save_with: as_xml)
123    end
124
125    it_behaves_like 'an accepted Response'
126
127    # Widening this to include the Assertion's signature would silently relax
128    # every `raise unless response.trusted?` guard written against 1.4.1.
129    it 'still reports trusted? for the Response element alone' do
130      expect(response).not_to be_trusted
131      expect(response.assertion).to be_trusted
132    end
133  end
134
135  # The opt-out exists so an operator whose identity provider signs nothing can
136  # stage the upgrade instead of pinning 1.4.1. It restores 1.4.1 behaviour
137  # exactly, forgeries included, which is why it defaults to on.
138  describe 'configuration.signature_required' do
139    let(:xml) do
140      document = Nokogiri::XML(signed_xml)
141      document.xpath('//ds:Signature', namespaces).each(&:remove)
142      document.to_xml(save_with: as_xml)
143    end
144
145    it 'defaults to requiring a signature' do
146      expect(sp_configuration.signature_required).to be(true)
147      expect(response).to be_invalid
148    end
149
150    it 'accepts an unsigned Response from a registered issuer when disabled' do
151      sp_configuration.signature_required = false
152      expect(response).to be_valid
153    end
154
155    it 'still refuses to call an unsigned Response trusted when disabled' do
156      sp_configuration.signature_required = false
157      response.valid?
158      expect(response).not_to be_trusted
159    end
160  end
161
162  describe 'error reporting' do
163    let(:xml) { signed_xml }
164
165    # The signature requirement has nothing to say about a document that is not
166    # a Response at all, and prepending to :base changes errors[:base].first.
167    it 'does not demand a signature from a document with no Response element' do
168      document = Saml::Kit::Response.new('', configuration: sp_configuration)
169      document.valid?
170      expect(document.errors[:base]).not_to include('must be signed.')
171    end
172
173    it 'reports a fingerprint mismatch once, not once per element' do
174      foreign = Saml::Kit::Configuration.new do |config|
175        config.entity_id = idp_entity_id
176        config.generate_key_pair_for(use: :signing)
177      end
178      document = Nokogiri::XML(
179        Saml::Kit::Response.build(user, request, configuration: foreign) do |x|
180          x.issuer = idp_entity_id
181          x.embed_signature = true
182        end.to_xml
183      )
184      document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
185      response = described_response(document.to_xml(save_with: as_xml))
186      response.valid?
187      expect(response.errors[:fingerprint].count).to be(1)
188    end
189  end
190
191  describe 'signature stripping' do
192    let(:xml) do
193      document = Nokogiri::XML(signed_xml)
194      document.xpath('//ds:Signature', namespaces).each(&:remove)
195      document.at_xpath('//saml:NameID', namespaces).content = 'attacker@evil.com'
196      document.to_xml(save_with: as_xml)
197    end
198
199    it_behaves_like 'a rejected forgery'
200
201    it 'reports the missing signature' do
202      response.valid?
203      expect(response.errors[:base]).to include('must be signed.')
204    end
205  end
206
207  describe 'a bare Assertion delivered as the whole document' do
208    subject(:assertion) do
209      Saml::Kit::Document.to_saml_document(xml, configuration: sp_configuration)
210    end
211
212    let(:bare) do
213      Nokogiri::XML(signed_xml)
214        .at_xpath('/samlp:Response/saml:Assertion', namespaces)
215    end
216
217    context 'when its signature is intact' do
218      let(:xml) { bare.to_xml(save_with: as_xml) }
219
220      it 'is accepted' do
221        expect(assertion).to be_a(Saml::Kit::Assertion)
222        expect(assertion).to be_valid
223      end
224    end
225
226    context 'when its signature has been stripped' do
227      let(:xml) do
228        bare.at_xpath('./ds:Signature', namespaces).remove
229        bare.at_xpath('.//saml:NameID', namespaces).content = 'attacker@evil.com'
230        bare.to_xml(save_with: as_xml)
231      end
232
233      it 'is invalid' do
234        expect(assertion).to be_invalid
235      end
236
237      # A missing locale key would surface here as "Translation missing".
238      it 'reports the missing signature' do
239        assertion.valid?
240        expect(assertion.errors[:base]).to include('must be signed.')
241      end
242    end
243  end
244
245  describe 'signature wrapping' do
246    context 'when the original assertion is hidden in saml:Advice' do
247      let(:xml) do
248        document = Nokogiri::XML(signed_xml)
249        document.at_xpath('/samlp:Response/ds:Signature', namespaces).remove
250        original = document.at_xpath('/samlp:Response/saml:Assertion', namespaces)
251
252        evil = original.dup
253        evil['ID'] = "_#{SecureRandom.uuid}"
254        evil.at_xpath('./saml:Subject/saml:NameID', namespaces).content = 'attacker@evil.com'
255
256        advice = Nokogiri::XML::Node.new('Advice', document)
257        advice.namespace = original.namespace
258        advice.add_child(original.dup)
259        evil.at_xpath('./saml:Conditions', namespaces).add_next_sibling(advice)
260
261        original.replace(evil)
262        document.to_xml(save_with: as_xml)
263      end
264
265      it_behaves_like 'a rejected forgery'
266
267      it 'reports the reference as unbound' do
268        response.valid?
269        expect(response.errors[:reference]).to be_present
270      end
271    end
272
273    context 'when the original response is hidden in ds:Object' do
274      let(:xml) do
275        document = Nokogiri::XML(response_signed_xml)
276        original = document.root
277        hidden = original.dup
278
279        evil = original.dup
280        evil['ID'] = "_#{SecureRandom.uuid}"
281        evil.at_xpath('.//saml:Assertion', namespaces)['ID'] = "_#{SecureRandom.uuid}"
282        evil.at_xpath('.//saml:NameID', namespaces).content = 'attacker@evil.com'
283
284        signature = evil.at_xpath('./ds:Signature', namespaces)
285        object = Nokogiri::XML::Node.new('Object', document)
286        object.namespace = signature.namespace
287        object.add_child(hidden)
288        signature.add_child(object)
289
290        document.root = evil
291        document.to_xml(save_with: as_xml)
292      end
293
294      it_behaves_like 'a rejected forgery'
295
296      it 'reports the reference as unbound' do
297        response.valid?
298        expect(response.errors[:reference]).to be_present
299      end
300    end
301  end
302end
303# rubocop:enable RSpec/DescribeClass