Skip to content

Commit bf6ee3c

Browse files
add additional tests
1 parent 864be35 commit bf6ee3c

File tree

1 file changed

+104
-27
lines changed

1 file changed

+104
-27
lines changed
Lines changed: 104 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,132 @@
11
require File.expand_path("test/test_helper")
22

33
describe Samlr do
4-
describe "invalid multiple saml responses" do
4+
# Zendesk acts as a SAML Service Provider (SP).
5+
describe "signature wrapping attack" do
56
let(:shared_id) { Samlr::Tools.uuid }
6-
let(:xml_response_doc) do
7-
options = {
8-
:destination => "https://example.org/saml/endpoint",
9-
:in_response_to => Samlr::Tools.uuid,
10-
:name_id => "[email protected]",
11-
:audience => "example.org",
12-
:not_on_or_after => Samlr::Tools::Timestamp.stamp(Time.now + 60),
13-
:not_before => Samlr::Tools::Timestamp.stamp(Time.now - 60),
14-
:response_id => Samlr::Tools.uuid,
15-
skip_conditions: true,
16-
sign_response: false,
17-
sign_assertion: false,
18-
assertion_id: shared_id,
19-
certificate: TEST_CERTIFICATE
20-
}
21-
Samlr::Tools::ResponseBuilder.build(options)
22-
end
7+
# Most IdPs publish a public, signed metadata file.
8+
# - The signature (<SignatureValue>) covers ONLY this EntityDescriptor and matches the data/ID exactly.
9+
# - This is meant to be public so SPs (like Zendesk) can get the IdP’s public keys for SSO.
2310
let(:xml_metadata_doc) do
2411
options = {
2512
:entity_id => "https://sp.example.com/saml2",
2613
:name_identity_format => "identity_format",
2714
:consumer_service_url => "https://support.sp.example.com/",
2815
:sign_metadata => true,
29-
metadata_id: shared_id,
30-
certificate: TEST_CERTIFICATE
16+
:metadata_id => shared_id,
17+
:certificate => TEST_CERTIFICATE
3118
}
3219
Samlr::Tools::MetadataBuilder.build(options)
3320
end
34-
3521
let(:fingerprint) { Samlr::Certificate.new(TEST_CERTIFICATE.x509).fingerprint.value }
36-
let(:saml_response) { Samlr::Response.new(Base64.encode64(xml_response_doc), fingerprint: fingerprint) }
3722

38-
it "succeeds" do
23+
it "is prevented by rejecting duplicate XML ID references" do
24+
# The attacker crafts their own <Assertion>, filling in any identity/attributes.
25+
options = {
26+
:destination => "https://example.org/saml/endpoint",
27+
:in_response_to => Samlr::Tools.uuid,
28+
:name_id => "[email protected]",
29+
:audience => "example.org",
30+
:not_on_or_after => Samlr::Tools::Timestamp.stamp(Time.now + 60),
31+
:not_before => Samlr::Tools::Timestamp.stamp(Time.now - 60),
32+
:response_id => Samlr::Tools.uuid,
33+
:skip_conditions => true,
34+
# This Assertion is not signed - meaning the attacker can put anything they want for the user, roles, etc.
35+
:sign_assertion => false,
36+
:sign_response => false,
37+
:assertion_id => shared_id,
38+
:certificate => TEST_CERTIFICATE
39+
}
40+
xml_response_doc = Samlr::Tools::ResponseBuilder.build(options)
41+
3942
metadata_doc = Nokogiri::XML(xml_metadata_doc)
4043
response_doc = Nokogiri::XML(xml_response_doc)
4144

42-
metadata_signature_doc = metadata_doc.xpath("md:EntityDescriptor/ds:Signature", Samlr::NS_MAP).first
43-
metadata_entity_descriptor_doc = metadata_doc.xpath("md:EntityDescriptor", Samlr::NS_MAP).first
44-
4545
assertion_doc = response_doc.xpath("/samlp:Response/saml:Assertion", Samlr::NS_MAP).first
46-
assertion_doc.xpath("saml:Subject/saml:NameID").first.content = "[email protected]"
46+
# The attacker embeds the entire valid, signed EntityDescriptor inside the assertion.
47+
# Now there are two elements with same ID in the same doc:
48+
# - The outer Assertion (attacker’s, unsigned, forged)
49+
# - The nested EntityDescriptor (genuine, validly signed, but only public metadata)
50+
metadata_entity_descriptor_doc = metadata_doc.xpath("md:EntityDescriptor", Samlr::NS_MAP).first
4751
assertion_doc.xpath("saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", Samlr::NS_MAP).first.add_child(metadata_entity_descriptor_doc.dup)
52+
# The attacker places everything in a single SAML Response document,
53+
# and uses the real <Signature> (copied from metadata) at the <Response> or <Assertion> level referencing the duplicate ID.
54+
#
55+
# The public signature and certificate are left untouched - copied exactly as found.
56+
# Only the outer Assertion is attacker-controlled (and unsigned).
57+
# The document is constructed so that, if a SAML parser looks for a valid signature on any element with the shared ID (and not just on the assertion),
58+
# it will pass signature validation on the wrong node (EntityDescriptor), and allows an attacker to log in with the fake assertion.
59+
# According to SAML schema, the <Signature> needs to be placed after <Issuer>.
60+
metadata_signature_doc = metadata_doc.xpath("md:EntityDescriptor/ds:Signature", Samlr::NS_MAP).first
4861
response_doc.at("/samlp:Response/saml:Issuer", Samlr::NS_MAP).add_next_sibling(metadata_signature_doc.dup)
4962

5063
crafted_saml_response = Samlr::Response.new(Base64.encode64(response_doc.to_xml), fingerprint: fingerprint)
64+
65+
# Checks for duplicate values of the ID attribute.
66+
# If two elements share the same ID, immediately treats the entire SAML Response as invalid.
67+
# Signature references (<Reference URI="#META123"/>) must always point to exactly one unique element according to XML DSig/SAML spec.
68+
# By rejecting duplicates, the signature can never be validated against a wrong element, like a nested public metadata node.
5169
error = assert_raises(Samlr::SignatureError) { crafted_saml_response.verify! }
5270
assert_equal "Expected 1 element with id #{shared_id}, found 2", error.details
5371
end
72+
73+
it "is prevented by not allowing signed and unsigned assertions with same ID" do
74+
options = {
75+
:destination => "https://example.org/saml/endpoint",
76+
:in_response_to => Samlr::Tools.uuid,
77+
:name_id => "[email protected]",
78+
:audience => "example.org",
79+
:not_on_or_after => Samlr::Tools::Timestamp.stamp(Time.now + 60),
80+
:not_before => Samlr::Tools::Timestamp.stamp(Time.now - 60),
81+
:assertion_id => shared_id,
82+
:skip_conditions => true,
83+
:sign_assertion => true,
84+
:sign_response => false,
85+
:certificate => TEST_CERTIFICATE
86+
}
87+
xml_response_doc = Samlr::Tools::ResponseBuilder.build(options)
88+
response_doc = Nokogiri::XML(xml_response_doc)
89+
assertion_doc = response_doc.xpath("/samlp:Response/saml:Assertion", Samlr::NS_MAP).first.dup
90+
# Duplicating the assertion with same ID, modified data and signature removed.
91+
assertion_doc.xpath("saml:Subject/saml:NameID").first.content = "[email protected]"
92+
assertion_doc.xpath("ds:Signature", Samlr::NS_MAP).first.remove
93+
response_doc.xpath("/samlp:Response", Samlr::NS_MAP).at("./samlp:Status", Samlr::NS_MAP).add_next_sibling(assertion_doc)
94+
95+
# Never processes login data from any assertion or attribute that is NOT covered by a valid signature.
96+
# It won't accept SAML Response with two assertions sharing same ID.
97+
error = assert_raises(Samlr::SamlrError) { Samlr::Response.new(Base64.encode64(response_doc.to_xml(Samlr::COMPACT)), fingerprint: fingerprint) }
98+
assert_equal "Schema validation failed", error.message
99+
assert_match %r{Assertion', attribute 'ID': '#{shared_id}' is not a valid value of the atomic type 'xs:ID'}, error.details
100+
end
101+
102+
it "is prevented by not allowing many assertions - signed and unsigned" do
103+
options = {
104+
:destination => "https://example.org/saml/endpoint",
105+
:in_response_to => Samlr::Tools.uuid,
106+
:name_id => "[email protected]",
107+
:audience => "example.org",
108+
:not_on_or_after => Samlr::Tools::Timestamp.stamp(Time.now + 60),
109+
:not_before => Samlr::Tools::Timestamp.stamp(Time.now - 60),
110+
:assertion_id => shared_id,
111+
:skip_conditions => true,
112+
:sign_assertion => true,
113+
:sign_response => false,
114+
:certificate => TEST_CERTIFICATE
115+
}
116+
xml_response_doc = Samlr::Tools::ResponseBuilder.build(options)
117+
response_doc = Nokogiri::XML(xml_response_doc)
118+
assertion_doc = response_doc.xpath("/samlp:Response/saml:Assertion", Samlr::NS_MAP).first.dup
119+
# Duplicating the assertion with different ID, modified data and signature removed.
120+
assertion_doc.xpath("saml:Subject/saml:NameID").first.content = "[email protected]"
121+
assertion_doc['ID'] = Samlr::Tools.uuid
122+
assertion_doc.xpath("ds:Signature", Samlr::NS_MAP).first.remove
123+
response_doc.xpath("/samlp:Response", Samlr::NS_MAP).at("./samlp:Status", Samlr::NS_MAP).add_next_sibling(assertion_doc)
124+
125+
# Never processes login data from any assertion or attribute that is NOT covered by a valid signature.
126+
# It won't accept SAML Response with two assertions.
127+
crafted_saml_response = Samlr::Response.new(Base64.encode64(response_doc.to_xml(Samlr::COMPACT)), fingerprint: fingerprint)
128+
error = assert_raises(Samlr::FormatError) { crafted_saml_response.verify! }
129+
assert_equal "Invalid SAML response: unexpected number of assertions", error.message
130+
end
54131
end
55132
end

0 commit comments

Comments
 (0)