Skip to content

Commit e9c1cdb

Browse files
committed
Fix vulnerabilities: CVE-2025-25291, CVE-2025-25292: SAML authentication bypass via Signature Wrapping attack allowed due parser differential
1 parent acac9e9 commit e9c1cdb

File tree

7 files changed

+208
-87
lines changed

7 files changed

+208
-87
lines changed

lib/onelogin/ruby-saml/logoutresponse.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ def validate_success_status
150150
# @raise [ValidationError] if soft == false and validation fails
151151
#
152152
def validate_structure
153-
unless valid_saml?(document, soft)
153+
check_malformed_doc = check_malformed_doc?(settings)
154+
unless valid_saml?(document, soft, check_malformed_doc)
154155
return append_error("Invalid SAML Logout Response. Not match the saml-schema-protocol-2.0.xsd")
155156
end
156157

lib/onelogin/ruby-saml/response.rb

Lines changed: 76 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ class Response < SamlMessage
1717
PROTOCOL = "urn:oasis:names:tc:SAML:2.0:protocol"
1818
DSIG = "http://www.w3.org/2000/09/xmldsig#"
1919
XENC = "http://www.w3.org/2001/04/xmlenc#"
20+
SAML_NAMESPACES = {
21+
"p" => PROTOCOL,
22+
"a" => ASSERTION
23+
}.freeze
2024

2125
# TODO: Settings should probably be initialized too... WDYT?
2226

@@ -303,7 +307,7 @@ def issuers
303307
issuer_response_nodes = REXML::XPath.match(
304308
document,
305309
"/p:Response/a:Issuer",
306-
{ "p" => PROTOCOL, "a" => ASSERTION }
310+
SAML_NAMESPACES
307311
)
308312

309313
unless issuer_response_nodes.size == 1
@@ -370,7 +374,7 @@ def assertion_encrypted?
370374
! REXML::XPath.first(
371375
document,
372376
"(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)",
373-
{ "p" => PROTOCOL, "a" => ASSERTION }
377+
SAML_NAMESPACES
374378
).nil?
375379
end
376380

@@ -401,9 +405,9 @@ def validate(collect_errors = false)
401405
:validate_id,
402406
:validate_success_status,
403407
:validate_num_assertion,
404-
:validate_no_duplicated_attributes,
405408
:validate_signed_elements,
406409
:validate_structure,
410+
:validate_no_duplicated_attributes,
407411
:validate_in_response_to,
408412
:validate_one_conditions,
409413
:validate_conditions,
@@ -444,12 +448,14 @@ def validate_success_status
444448
#
445449
def validate_structure
446450
structure_error_msg = "Invalid SAML Response. Not match the saml-schema-protocol-2.0.xsd"
447-
unless valid_saml?(document, soft)
451+
452+
check_malformed_doc = check_malformed_doc_enabled?
453+
unless valid_saml?(document, soft, check_malformed_doc)
448454
return append_error(structure_error_msg)
449455
end
450456

451457
unless decrypted_document.nil?
452-
unless valid_saml?(decrypted_document, soft)
458+
unless valid_saml?(decrypted_document, soft, check_malformed_doc)
453459
return append_error(structure_error_msg)
454460
end
455461
end
@@ -841,31 +847,47 @@ def validate_name_id
841847
true
842848
end
843849

850+
def doc_to_validate
851+
# If the response contains the signature, and the assertion was encrypted, validate the original SAML Response
852+
# otherwise, review if the decrypted assertion contains a signature
853+
sig_elements = REXML::XPath.match(
854+
document,
855+
"/p:Response[@ID=$id]/ds:Signature",
856+
{ "p" => PROTOCOL, "ds" => DSIG },
857+
{ 'id' => document.signed_element_id }
858+
)
859+
860+
use_original = sig_elements.size == 1 || decrypted_document.nil?
861+
doc = use_original ? document : decrypted_document
862+
if !doc.processed
863+
doc.cache_referenced_xml(@soft, check_malformed_doc_enabled?)
864+
end
865+
866+
return doc
867+
end
868+
844869
# Validates the Signature
845870
# @return [Boolean] True if not contains a Signature or if the Signature is valid, otherwise False if soft=True
846871
# @raise [ValidationError] if soft == false and validation fails
847872
#
848873
def validate_signature
849874
error_msg = "Invalid Signature on SAML Response"
850875

851-
# If the response contains the signature, and the assertion was encrypted, validate the original SAML Response
852-
# otherwise, review if the decrypted assertion contains a signature
876+
doc = doc_to_validate
877+
853878
sig_elements = REXML::XPath.match(
854879
document,
855880
"/p:Response[@ID=$id]/ds:Signature",
856881
{ "p" => PROTOCOL, "ds" => DSIG },
857882
{ 'id' => document.signed_element_id }
858883
)
859884

860-
use_original = sig_elements.size == 1 || decrypted_document.nil?
861-
doc = use_original ? document : decrypted_document
862-
863-
# Check signature nodes
885+
# Check signature node inside assertion
864886
if sig_elements.nil? || sig_elements.size == 0
865887
sig_elements = REXML::XPath.match(
866888
doc,
867889
"/p:Response/a:Assertion[@ID=$id]/ds:Signature",
868-
{"p" => PROTOCOL, "a" => ASSERTION, "ds"=>DSIG},
890+
SAML_NAMESPACES.merge({"ds"=>DSIG}),
869891
{ 'id' => doc.signed_element_id }
870892
)
871893
end
@@ -943,24 +965,47 @@ def name_id_node
943965
end
944966
end
945967

968+
def get_cached_signed_assertion
969+
xml = doc_to_validate.referenced_xml
970+
empty_doc = REXML::Document.new
971+
972+
return empty_doc if xml.nil? # when no signature/reference is found, return empty document
973+
974+
root = REXML::Document.new(xml).root
975+
976+
if root.attributes["ID"] != doc_to_validate.signed_element_id
977+
return empty_doc
978+
end
979+
980+
assertion = empty_doc
981+
if root.name == "Response"
982+
if REXML::XPath.first(root, "a:Assertion", {"a" => ASSERTION})
983+
assertion = REXML::XPath.first(root, "a:Assertion", {"a" => ASSERTION})
984+
elsif REXML::XPath.first(root, "a:EncryptedAssertion", {"a" => ASSERTION})
985+
assertion = decrypt_assertion(REXML::XPath.first(root, "a:EncryptedAssertion", {"a" => ASSERTION}))
986+
end
987+
elsif root.name == "Assertion"
988+
assertion = root
989+
end
990+
991+
assertion
992+
end
993+
994+
def signed_assertion
995+
@signed_assertion ||= get_cached_signed_assertion
996+
end
997+
946998
# Extracts the first appearance that matchs the subelt (pattern)
947999
# Search on any Assertion that is signed, or has a Response parent signed
9481000
# @param subelt [String] The XPath pattern
9491001
# @return [REXML::Element | nil] If any matches, return the Element
9501002
#
9511003
def xpath_first_from_signed_assertion(subelt=nil)
952-
doc = decrypted_document.nil? ? document : decrypted_document
1004+
doc = signed_assertion
9531005
node = REXML::XPath.first(
9541006
doc,
955-
"/p:Response/a:Assertion[@ID=$id]#{subelt}",
956-
{ "p" => PROTOCOL, "a" => ASSERTION },
957-
{ 'id' => doc.signed_element_id }
958-
)
959-
node ||= REXML::XPath.first(
960-
doc,
961-
"/p:Response[@ID=$id]/a:Assertion#{subelt}",
962-
{ "p" => PROTOCOL, "a" => ASSERTION },
963-
{ 'id' => doc.signed_element_id }
1007+
"./#{subelt}",
1008+
SAML_NAMESPACES
9641009
)
9651010
node
9661011
end
@@ -971,19 +1016,13 @@ def xpath_first_from_signed_assertion(subelt=nil)
9711016
# @return [Array of REXML::Element] Return all matches
9721017
#
9731018
def xpath_from_signed_assertion(subelt=nil)
974-
doc = decrypted_document.nil? ? document : decrypted_document
1019+
doc = signed_assertion
9751020
node = REXML::XPath.match(
9761021
doc,
977-
"/p:Response/a:Assertion[@ID=$id]#{subelt}",
978-
{ "p" => PROTOCOL, "a" => ASSERTION },
979-
{ 'id' => doc.signed_element_id }
1022+
"./#{subelt}",
1023+
SAML_NAMESPACES
9801024
)
981-
node.concat( REXML::XPath.match(
982-
doc,
983-
"/p:Response[@ID=$id]/a:Assertion#{subelt}",
984-
{ "p" => PROTOCOL, "a" => ASSERTION },
985-
{ 'id' => doc.signed_element_id }
986-
))
1025+
node
9871026
end
9881027

9891028
# Generates the decrypted_document
@@ -1017,7 +1056,7 @@ def decrypt_assertion_from_document(document_copy)
10171056
encrypted_assertion_node = REXML::XPath.first(
10181057
document_copy,
10191058
"(/p:Response/EncryptedAssertion/)|(/p:Response/a:EncryptedAssertion/)",
1020-
{ "p" => PROTOCOL, "a" => ASSERTION }
1059+
SAML_NAMESPACES
10211060
)
10221061
response_node.add(decrypt_assertion(encrypted_assertion_node))
10231062
encrypted_assertion_node.remove
@@ -1087,6 +1126,10 @@ def parse_time(node, attribute)
10871126
Time.parse(node.attributes[attribute])
10881127
end
10891128
end
1129+
1130+
def check_malformed_doc_enabled?
1131+
check_malformed_doc?(settings)
1132+
end
10901133
end
10911134
end
10921135
end

lib/onelogin/ruby-saml/saml_message.rb

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,13 @@ def id(document)
5858
# Validates the SAML Message against the specified schema.
5959
# @param document [REXML::Document] The message that will be validated
6060
# @param soft [Boolean] soft Enable or Disable the soft mode (In order to raise exceptions when the message is invalid or not)
61+
# @param check_malformed_doc [Boolean] check_malformed_doc Enable or Disable the check for malformed XML
6162
# @return [Boolean] True if the XML is valid, otherwise False, if soft=True
6263
# @raise [ValidationError] if soft == false and validation fails
6364
#
64-
def valid_saml?(document, soft = true)
65+
def valid_saml?(document, soft = true, check_malformed_doc = true)
6566
begin
66-
xml = Nokogiri::XML(document.to_s) do |config|
67-
config.options = XMLSecurity::BaseDocument::NOKOGIRI_OPTIONS
68-
end
67+
xml = XMLSecurity::BaseDocument.safe_load_xml(document, check_malformed_doc)
6968
rescue StandardError => error
7069
return false if soft
7170
raise ValidationError.new("XML load failed: #{error.message}")
@@ -81,6 +80,7 @@ def valid_saml?(document, soft = true)
8180

8281
# Base64 decode and try also to inflate a SAML Message
8382
# @param saml [String] The deflated and encoded SAML Message
83+
# @param settings [OneLogin::RubySaml::Settings|nil] Toolkit settings
8484
# @return [String] The plain SAML Message
8585
#
8686
def decode_raw_saml(saml, settings = nil)
@@ -93,13 +93,13 @@ def decode_raw_saml(saml, settings = nil)
9393

9494
decoded = decode(saml)
9595
begin
96-
message = inflate(decoded)
96+
message = inflate(decoded)
9797
rescue
98-
message = decoded
98+
message = decoded
9999
end
100100

101101
if message.bytesize > settings.message_max_bytesize
102-
raise ValidationError.new("Encoded SAML Message exceeds " + settings.message_max_bytesize.to_s + " bytes, so was rejected")
102+
raise ValidationError.new("SAML Message exceeds " + settings.message_max_bytesize.to_s + " bytes, so was rejected")
103103
end
104104

105105
message
@@ -159,6 +159,12 @@ def inflate(deflated)
159159
def deflate(inflated)
160160
Zlib::Deflate.deflate(inflated, 9)[2..-5]
161161
end
162+
163+
def check_malformed_doc?(settings)
164+
default_value = OneLogin::RubySaml::Settings::DEFAULTS[:check_malformed_doc]
165+
166+
settings.nil? ? default_value : settings.check_malformed_doc
167+
end
162168
end
163169
end
164170
end

lib/onelogin/ruby-saml/settings.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ def initialize(overrides = {}, keep_security_attributes = false)
5555
attr_accessor :compress_response
5656
attr_accessor :double_quote_xml_attribute_values
5757
attr_accessor :message_max_bytesize
58+
attr_accessor :check_malformed_doc
5859
attr_accessor :passive
5960
attr_reader :protocol_binding
6061
attr_accessor :attributes_index
@@ -281,7 +282,9 @@ def get_binding(value)
281282
:compress_response => true,
282283
:message_max_bytesize => 250000,
283284
:soft => true,
285+
:check_malformed_doc => true,
284286
:double_quote_xml_attribute_values => false,
287+
285288
:security => {
286289
:authn_requests_signed => false,
287290
:logout_requests_signed => false,

lib/onelogin/ruby-saml/slo_logoutrequest.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,8 @@ def validate_not_on_or_after
238238
# @raise [ValidationError] if soft == false and validation fails
239239
#
240240
def validate_structure
241-
unless valid_saml?(document, soft)
241+
check_malformed_doc = check_malformed_doc?(settings)
242+
unless valid_saml?(document, soft, check_malformed_doc)
242243
return append_error("Invalid SAML Logout Request. Not match the saml-schema-protocol-2.0.xsd")
243244
end
244245

0 commit comments

Comments
 (0)