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