Skip to content

Commit b5c6c4d

Browse files
committed
Several security improvements:
- Conditions element required and unique. - AuthnStatement element required and unique. - SPNameQualifier must math the SP EntityID - Reject saml:Attribute element with same “Name” attribute - Reject empty nameID - Require Issuer element. (Must match IdP EntityID). - Destination value can't be blank (if present must match ACS URL). - Check that the EncryptedAssertion element only contains 1 Assertion element.
1 parent 20489a0 commit b5c6c4d

13 files changed

+268
-39
lines changed

lib/onelogin/ruby-saml/response.rb

Lines changed: 150 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,23 @@ def name_id_format
8888

8989
alias_method :nameid_format, :name_id_format
9090

91+
# @return [String] the NameID SPNameQualifier provided by the SAML response from the IdP.
92+
#
93+
def name_id_spnamequalifier
94+
@name_id_spnamequalifier ||=
95+
if name_id_node && name_id_node.attribute("SPNameQualifier")
96+
name_id_node.attribute("SPNameQualifier").value
97+
end
98+
end
99+
100+
# @return [String] the NameID NameQualifier provided by the SAML response from the IdP.
101+
#
102+
def name_id_namequalifier
103+
@name_id_namequalifier ||=
104+
if name_id_node && name_id_node.attribute("NameQualifier")
105+
name_id_node.attribute("NameQualifier").value
106+
end
107+
end
91108

92109
# Gets the SessionIndex from the AuthnStatement.
93110
# Could be used to be stored in the local session in order
@@ -115,15 +132,15 @@ def sessionindex
115132
# attributes['name']
116133
#
117134
# @return [Attributes] OneLogin::RubySaml::Attributes enumerable collection.
118-
#
135+
#
119136
def attributes
120137
@attr_statements ||= begin
121138
attributes = Attributes.new
122139

123140
stmt_elements = xpath_from_signed_assertion('/a:AttributeStatement')
124141
stmt_elements.each do |stmt_element|
125142
stmt_element.elements.each do |attr_element|
126-
name = attr_element.attributes["Name"]
143+
name = attr_element.attributes["Name"]
127144
values = attr_element.elements.collect{|e|
128145
if (e.elements.nil? || e.elements.size == 0)
129146
# SAMLCore requires that nil AttributeValues MUST contain xsi:nil XML attribute set to "true" or "1"
@@ -169,25 +186,31 @@ def success?
169186
#
170187
def status_code
171188
@status_code ||= begin
172-
node = REXML::XPath.first(
189+
nodes = REXML::XPath.match(
173190
document,
174191
"/p:Response/p:Status/p:StatusCode",
175192
{ "p" => PROTOCOL }
176193
)
177-
node.attributes["Value"] if node && node.attributes
194+
if nodes.size == 1
195+
node = nodes[0]
196+
node.attributes["Value"] if node && node.attributes
197+
end
178198
end
179199
end
180200

181201
# @return [String] the StatusMessage value from a SAML Response.
182202
#
183203
def status_message
184204
@status_message ||= begin
185-
node = REXML::XPath.first(
205+
nodes = REXML::XPath.match(
186206
document,
187207
"/p:Response/p:Status/p:StatusMessage",
188208
{ "p" => PROTOCOL }
189209
)
190-
node.text if node
210+
if nodes.size == 1
211+
node = nodes[0]
212+
node.text if node
213+
end
191214
end
192215
end
193216

@@ -213,26 +236,6 @@ def not_on_or_after
213236
@not_on_or_after ||= parse_time(conditions, "NotOnOrAfter")
214237
end
215238

216-
# Gets the Issuers (from Response and Assertion).
217-
# (returns the first node that matches the supplied xpath from the Response and from the Assertion)
218-
# @return [Array] Array with the Issuers (REXML::Element)
219-
#
220-
def issuers
221-
@issuers ||= begin
222-
issuers = []
223-
nodes = REXML::XPath.match(
224-
document,
225-
"/p:Response/a:Issuer",
226-
{ "p" => PROTOCOL, "a" => ASSERTION }
227-
)
228-
nodes += xpath_from_signed_assertion("/a:Issuer")
229-
nodes.each do |node|
230-
issuers << node.text if node.text
231-
end
232-
issuers.uniq
233-
end
234-
end
235-
236239
# @return [String|nil] The InResponseTo attribute from the SAML Response.
237240
#
238241
def in_response_to
@@ -298,15 +301,19 @@ def validate(collect_errors = false)
298301
:validate_success_status,
299302
:validate_num_assertion,
300303
:validate_no_encrypted_attributes,
304+
:validate_no_duplicated_attributes,
301305
:validate_signed_elements,
302306
:validate_structure,
303307
:validate_in_response_to,
308+
:validate_one_conditions,
304309
:validate_conditions,
310+
:validate_one_authnstatement,
305311
:validate_audience,
306312
:validate_destination,
307313
:validate_issuer,
308314
:validate_session_expiration,
309315
:validate_subject_confirmation,
316+
:validate_name_id,
310317
:validate_signature
311318
]
312319

@@ -395,6 +402,7 @@ def validate_version
395402
# @return [Boolean] True if the SAML Response contains one unique Assertion, otherwise False
396403
#
397404
def validate_num_assertion
405+
error_msg = "SAML Response must contain 1 assertion"
398406
assertions = REXML::XPath.match(
399407
document,
400408
"//a:Assertion",
@@ -407,7 +415,18 @@ def validate_num_assertion
407415
)
408416

409417
unless assertions.size + encrypted_assertions.size == 1
410-
return append_error("SAML Response must contain 1 assertion")
418+
return append_error(error_msg)
419+
end
420+
421+
unless decrypted_document.nil?
422+
assertions = REXML::XPath.match(
423+
decrypted_document,
424+
"//a:Assertion",
425+
{ "a" => ASSERTION }
426+
)
427+
unless assertions.size == 1
428+
return append_error(error_msg)
429+
end
411430
end
412431

413432
true
@@ -427,6 +446,28 @@ def validate_no_encrypted_attributes
427446
true
428447
end
429448

449+
# Validates that there are not duplicated attributes
450+
# If fails, the error is added to the errors array
451+
# @return [Boolean] True if there are no duplicated attribute elements, otherwise False if soft=True
452+
# @raise [ValidationError] if soft == false and validation fails
453+
#
454+
def validate_no_duplicated_attributes
455+
if options[:check_duplicated_attributes]
456+
processed_names = []
457+
stmt_elements = xpath_from_signed_assertion('/a:AttributeStatement')
458+
stmt_elements.each do |stmt_element|
459+
stmt_element.elements.each do |attr_element|
460+
name = attr_element.attributes["Name"]
461+
if attributes.include?(name)
462+
return append_error("Found an Attribute element with duplicated Name")
463+
end
464+
processed_names.add(name)
465+
end
466+
end
467+
end
468+
469+
true
470+
end
430471

431472
# Validates the Signed elements
432473
# If fails, the error is added to the errors array
@@ -526,7 +567,14 @@ def validate_audience
526567
# @return [Boolean] True if there is a Destination element that matches the Consumer Service URL, otherwise False
527568
#
528569
def validate_destination
529-
return true if destination.nil? || destination.empty? || settings.assertion_consumer_service_url.nil? || settings.assertion_consumer_service_url.empty?
570+
return true if destination.nil?
571+
572+
if destination.empty?
573+
error_msg = "The response has an empty Destination value"
574+
return append_error(error_msg)
575+
end
576+
577+
return true if settings.assertion_consumer_service_url.nil? || settings.assertion_consumer_service_url.empty?
530578

531579
unless destination == settings.assertion_consumer_service_url
532580
error_msg = "The response was received at #{destination} instead of #{settings.assertion_consumer_service_url}"
@@ -536,6 +584,34 @@ def validate_destination
536584
true
537585
end
538586

587+
# Checks that the samlp:Response/saml:Assertion/saml:Conditions element exists and is unique.
588+
# If fails, the error is added to the errors array
589+
# @return [Boolean] True if there is a conditions element and is unique
590+
#
591+
def validate_one_conditions
592+
conditions_nodes = xpath_from_signed_assertion('/a:Conditions')
593+
unless conditions_nodes.size == 1
594+
error_msg = "The Assertion must include one Conditions element"
595+
return append_error(error_msg)
596+
end
597+
598+
true
599+
end
600+
601+
# Checks that the samlp:Response/saml:Assertion/saml:AuthnStatement element exists and is unique.
602+
# If fails, the error is added to the errors array
603+
# @return [Boolean] True if there is a authnstatement element and is unique
604+
#
605+
def validate_one_authnstatement
606+
authnstatement_nodes = xpath_from_signed_assertion('/a:AuthnStatement')
607+
unless authnstatement_nodes.size == 1
608+
error_msg = "The Assertion must include one AuthnStatement element"
609+
return append_error(error_msg)
610+
end
611+
612+
true
613+
end
614+
539615
# Validates the Conditions. (If the response was initialized with the :skip_conditions option, this validation is skipped,
540616
# If the response was initialized with the :allowed_clock_drift option, the timing validations are relaxed by the allowed_clock_drift value)
541617
# @return [Boolean] True if satisfies the conditions, otherwise False if soft=True
@@ -568,6 +644,31 @@ def validate_conditions
568644
def validate_issuer
569645
return true if settings.idp_entity_id.nil?
570646

647+
issuers = []
648+
issuer_response_nodes = REXML::XPath.match(
649+
document,
650+
"/p:Response/a:Issuer",
651+
{ "p" => PROTOCOL, "a" => ASSERTION }
652+
)
653+
654+
unless issuer_response_nodes.size == 1
655+
error_msg = "Issuer of the Response not found or multiple."
656+
return append_error(error_msg)
657+
end
658+
659+
doc = decrypted_document.nil? ? document : decrypted_document
660+
issuer_assertion_nodes = xpath_from_signed_assertion("/a:Issuer")
661+
unless issuer_assertion_nodes.size == 1
662+
error_msg = "Issuer of the Assertion not found or multiple."
663+
return append_error(error_msg)
664+
end
665+
666+
nodes = issuer_response_nodes + issuer_assertion_nodes
667+
nodes.each do |node|
668+
issuers << node.text if node.text
669+
end
670+
issuers.uniq
671+
571672
issuers.each do |issuer|
572673
unless URI.parse(issuer) == URI.parse(settings.idp_entity_id)
573674
error_msg = "Doesn't match the issuer, expected: <#{settings.idp_entity_id}>, but was: <#{issuer}>"
@@ -641,6 +742,27 @@ def validate_subject_confirmation
641742
true
642743
end
643744

745+
# Validates the NameID element
746+
def validate_name_id
747+
if name_id_node.nil?
748+
if settings.security[:want_name_id]
749+
return append_error("No NameID element found in the assertion of the Response")
750+
end
751+
else
752+
if name_id.nil? || name_id.empty?
753+
return append_error("An empty NameID value found")
754+
end
755+
756+
unless settings.issuer.nil? || settings.issuer.empty? || name_id_spnamequalifier.nil? || name_id_spnamequalifier.empty?
757+
if name_id_spnamequalifier != settings.issuer
758+
return append_error("The SPNameQualifier value mistmatch the SP entityID value.")
759+
end
760+
end
761+
end
762+
763+
true
764+
end
765+
644766
# Validates the Signature
645767
# @return [Boolean] True if not contains a Signature or if the Signature is valid, otherwise False if soft=True
646768
# @raise [ValidationError] if soft == false and validation fails

lib/onelogin/ruby-saml/settings.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ def get_sp_key
155155
:logout_requests_signed => false,
156156
:logout_responses_signed => false,
157157
:want_assertions_signed => false,
158+
:want_name_id => false,
158159
:metadata_signed => false,
159160
:embed_sign => false,
160161
:digest_method => XMLSecurity::Document::SHA1,

0 commit comments

Comments
 (0)