Skip to content

Commit e3b1542

Browse files
committed
Refactor of safe_load_xml method. Improve how handle XML that contains syntax errors
1 parent bd87168 commit e3b1542

File tree

12 files changed

+234
-61
lines changed

12 files changed

+234
-61
lines changed

lib/ruby_saml/idp_metadata_parser.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def parse_to_array(idp_metadata, options = {})
161161
end
162162

163163
def parse_to_idp_metadata_array(idp_metadata, options = {})
164-
@document = Nokogiri::XML(idp_metadata) # TODO: RubySaml::XML.safe_load_nokogiri
164+
@document = RubySaml::XML.safe_load_xml(idp_metadata, check_malformed_doc: true)
165165
@options = options
166166

167167
idpsso_descriptors = self.class.get_idps(@document, options[:entity_id])

lib/ruby_saml/logoutresponse.rb

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ def initialize(response, settings = nil, options = {})
4141

4242
@options = options
4343
@response = RubySaml::XML::Decoder.decode_message(response, @settings&.message_max_bytesize)
44-
@document = RubySaml::XML.safe_load_nokogiri(@response)
44+
begin
45+
@document = RubySaml::XML.safe_load_xml(@response, check_malformed_doc: @soft)
46+
rescue StandardError => e
47+
@errors << e.message if e.message != "Empty document"
48+
return false if @soft
49+
raise ValidationError.new("XML load failed: #{e.message}") if e.message != "Empty document"
50+
end
51+
4552
super()
4653
end
4754

@@ -136,9 +143,13 @@ def validate_success_status
136143
# @raise [ValidationError] if soft == false and validation fails
137144
#
138145
def validate_structure
146+
structure_error_msg = "Invalid SAML Logout Response. Not match the saml-schema-protocol-2.0.xsd"
147+
148+
doc_to_analize = @document.nil? ? @response : @document
149+
139150
check_malformed_doc = check_malformed_doc?(settings)
140-
unless valid_saml?(document, soft, check_malformed_doc: check_malformed_doc)
141-
return append_error("Invalid SAML Logout Response. Not match the saml-schema-protocol-2.0.xsd")
151+
unless valid_saml?(doc_to_analize, soft, check_malformed_doc: check_malformed_doc)
152+
return append_error(structure_error_msg)
142153
end
143154

144155
true

lib/ruby_saml/response.rb

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
4+
require "ruby_saml/settings"
35
require "ruby_saml/xml"
46
require "ruby_saml/attributes"
57
require "time"
@@ -52,18 +54,29 @@ def initialize(response, options = {})
5254

5355
@options = options
5456
@soft = true
57+
message_max_bytesize = nil
5558
unless options[:settings].nil?
5659
@settings = options[:settings]
57-
unless @settings.soft.nil?
58-
@soft = @settings.soft
59-
end
60+
61+
raise ValidationError.new("Invalid settings type: expected RubySaml::Settings, got #{@settings. class. name}") if !@settings.is_a?(Settings) && !@settings.nil?
62+
63+
@soft = @settings&.respond_to?(:soft) && !@settings.soft.nil? ? @settings.soft : true
64+
message_max_bytesize = @settings.message_max_bytesize if @settings.respond_to?(:message_max_bytesize)
6065
end
6166

62-
@response = RubySaml::XML::Decoder.decode_message(response, @settings&.message_max_bytesize)
63-
@document = RubySaml::XML.safe_load_nokogiri(@response)
67+
@response = RubySaml::XML::Decoder.decode_message(response, message_max_bytesize)
68+
begin
69+
@document = RubySaml::XML.safe_load_xml(@response, check_malformed_doc: @soft)
70+
rescue StandardError => e
71+
@errors << "XML load failed: #{e.message}" if e.message != "Empty document"
72+
return false if @soft
73+
raise ValidationError.new("XML load failed: #{e.message}") if e.message != "Empty document"
74+
end
6475

65-
if assertion_encrypted?
66-
@decrypted_document = generate_decrypted_document
76+
unless @document.nil?
77+
if assertion_encrypted?
78+
@decrypted_document = generate_decrypted_document
79+
end
6780
end
6881

6982
super()
@@ -131,6 +144,8 @@ def sessionindex
131144
# @raise [ValidationError] if there are 2+ Attribute with the same Name
132145
#
133146
def attributes
147+
return nil if @document.nil?
148+
134149
@attr_statements ||= begin
135150
attributes = Attributes.new
136151

@@ -367,6 +382,9 @@ def assertion_id
367382
#
368383
def validate(collect_errors = false)
369384
reset_errors!
385+
386+
return append_error("Blank response") if @document.nil?
387+
370388
return false unless validate_response_state
371389

372390
validations = %i[
@@ -417,8 +435,10 @@ def validate_success_status
417435
def validate_structure
418436
structure_error_msg = "Invalid SAML Response. Not match the saml-schema-protocol-2.0.xsd"
419437

438+
doc_to_analize = @document.nil? ? @response : @document
439+
420440
check_malformed_doc = check_malformed_doc_enabled?
421-
unless valid_saml?(document, soft, check_malformed_doc: check_malformed_doc)
441+
unless valid_saml?(doc_to_analize, soft, check_malformed_doc: check_malformed_doc)
422442
return append_error(structure_error_msg)
423443
end
424444

@@ -900,6 +920,8 @@ def validate_signature
900920
end
901921

902922
def name_id_node
923+
return nil if @document.nil?
924+
903925
@name_id_node ||=
904926
begin
905927
encrypted_node = xpath_first_from_signed_assertion('/a:Subject/a:EncryptedID')

lib/ruby_saml/saml_message.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ def id(document)
2929
end
3030

3131
def root_attribute(document, attribute)
32+
return nil if document.nil?
33+
3234
document.at_xpath(
3335
"/p:AuthnRequest | /p:Response | /p:LogoutResponse | /p:LogoutRequest",
3436
{ "p" => RubySaml::XML::NS_PROTOCOL }
@@ -43,10 +45,10 @@ def root_attribute(document, attribute)
4345
# @raise [ValidationError] if soft == false and validation fails
4446
def valid_saml?(document, soft = true, check_malformed_doc: true)
4547
begin
46-
xml = RubySaml::XML.safe_load_nokogiri(document, check_malformed_doc: check_malformed_doc)
48+
xml = RubySaml::XML.safe_load_xml(document, check_malformed_doc: check_malformed_doc)
4749
rescue StandardError => error
4850
return false if soft
49-
raise ValidationError.new("XML load failed: #{error.message}")
51+
raise ValidationError.new("XML load failed: #{error.message}") if error.message != "Empty document"
5052
end
5153

5254
SamlMessage.schema.validate(xml).each do |schema_error|

lib/ruby_saml/slo_logoutrequest.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,14 @@ def initialize(request, options = {})
4040
end
4141

4242
@request = RubySaml::XML::Decoder.decode_message(request, @settings&.message_max_bytesize)
43-
@document = RubySaml::XML.safe_load_nokogiri(@request)
43+
begin
44+
@document = RubySaml::XML.safe_load_xml(@request, check_malformed_doc: @soft)
45+
rescue StandardError => e
46+
@errors << e.message if e.message != "Empty document"
47+
return false if @soft
48+
raise ValidationError.new("XML load failed: #{e.message}") if e.message != "Empty document"
49+
end
50+
4451
super()
4552
end
4653

@@ -157,6 +164,8 @@ def validate(collect_errors = false)
157164
# @return [Boolean] True if the Logout Request contains an ID, otherwise returns False
158165
#
159166
def validate_id
167+
return append_error("Missing ID attribute on Logout Request") if document.nil?
168+
160169
return true if id
161170
append_error("Missing ID attribute on Logout Request")
162171
end
@@ -166,6 +175,8 @@ def validate_id
166175
# @return [Boolean] True if the Logout Request is 2.0, otherwise returns False
167176
#
168177
def validate_version
178+
return append_error("Unsupported SAML version") if document.nil?
179+
169180
return true if version(document) == "2.0"
170181
append_error("Unsupported SAML version")
171182
end
@@ -191,8 +202,10 @@ def validate_not_on_or_after
191202
# @raise [ValidationError] if soft == false and validation fails
192203
#
193204
def validate_structure
205+
doc_to_analize = @document.nil? ? @request : @document
206+
194207
check_malformed_doc = check_malformed_doc?(settings)
195-
unless valid_saml?(document, soft, check_malformed_doc: check_malformed_doc)
208+
unless valid_saml?(doc_to_analize, soft, check_malformed_doc: check_malformed_doc)
196209
return append_error("Invalid SAML Logout Request. Not match the saml-schema-protocol-2.0.xsd")
197210
end
198211

lib/ruby_saml/xml.rb

Lines changed: 15 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -49,49 +49,34 @@ module XML
4949
NOKOGIRI_OPTIONS = Nokogiri::XML::ParseOptions::STRICT |
5050
Nokogiri::XML::ParseOptions::NONET
5151

52-
# TODO: safe_load_message (rename safe_load_nokogiri --> safe_load_xml)
53-
# def safe_load_message(message, check_malformed_doc: true)
54-
# message = Decoder.decode(message)
55-
# begin
56-
# safe_load_nokogiri(message, check_malformed_doc: check_malformed_doc)
57-
# rescue RubySaml::Errors::XMLLoadError
58-
# Nokogiri::XML::Document.new
59-
# end
60-
# end
61-
6252
# Safely load the SAML Message XML.
6353
# @param document [String | Nokogiri::XML::Document] The message to be loaded
6454
# @param check_malformed_doc [Boolean] check_malformed_doc Enable or Disable the check for malformed XML
6555
# @return [Nokogiri::XML::Document] The nokogiri document
66-
# @raise [ValidationError] If there was a problem loading the SAML Message XML
67-
def safe_load_nokogiri(document, check_malformed_doc: true)
56+
# @raise [StandardError] If there was a problem loading the SAML Message XML
57+
def safe_load_xml(document, check_malformed_doc: true)
6858
doc_str = document.to_s
69-
error = nil
70-
error = StandardError.new('Dangerous XML detected. No Doctype nodes allowed') if doc_str.include?('<!DOCTYPE')
71-
72-
xml = nil
73-
unless error
74-
begin
75-
xml = Nokogiri::XML(doc_str) do |config|
76-
config.options = NOKOGIRI_OPTIONS
77-
end
78-
rescue StandardError => e
79-
error ||= e
80-
# raise StandardError.new(e.message)
59+
raise StandardError.new('Dangerous XML detected. No Doctype nodes allowed') if doc_str.include?('<!DOCTYPE')
60+
61+
begin
62+
xml = Nokogiri::XML(doc_str) do |config|
63+
config.options = NOKOGIRI_OPTIONS
8164
end
65+
rescue StandardError => e
66+
raise StandardError.new(e.message)
67+
rescue SyntaxError => e
68+
raise StandardError.new(e.message) if check_malformed_doc && e.message != "Empty document"
8269
end
8370

84-
# TODO: This is messy, its shims how the old REXML parser works
85-
if xml
86-
error ||= StandardError.new('Dangerous XML detected. No Doctype nodes allowed') if xml.internal_subset
87-
error ||= StandardError.new("There were XML errors when parsing: #{xml.errors}") if check_malformed_doc && !xml.errors.empty?
71+
if xml && xml.is_a?(Nokogiri::XML::Document)
72+
StandardError.new('Dangerous XML detected. No Doctype nodes allowed') if xml.internal_subset
73+
StandardError.new("There were XML errors when parsing: #{xml.errors}") if check_malformed_doc && !xml.errors.empty?
8874
end
89-
return Nokogiri::XML::Document.new if error || !xml
9075

9176
xml
9277
end
9378

94-
def copy_nokogiri(noko)
79+
def copy_xml(noko)
9580
Nokogiri::XML(noko.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML)) do |config|
9681
config.options = NOKOGIRI_OPTIONS
9782
end

lib/ruby_saml/xml/decoder.rb

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ def decode_message(message, max_bytesize = nil)
2323

2424
return message unless base64_encoded?(message)
2525

26-
message = try_inflate(base64_decode(message))
26+
message = try_inflate(base64_decode(message), max_bytesize)
2727

2828
if message.bytesize > max_bytesize # rubocop:disable Style/IfUnlessModifier
2929
raise ValidationError.new("SAML Message exceeds #{max_bytesize} bytes, so was rejected")
@@ -61,23 +61,52 @@ def base64_encode(string)
6161
# @param string [String] string to check the encoding of
6262
# @return [true, false] whether or not the string is base64 encoded
6363
def base64_encoded?(string)
64-
string.gsub(/[\s\r\n]|\\r|\\n/, '').match?(BASE64_FORMAT)
64+
string.gsub(/\s|\\r|\\n/, '').match?(BASE64_FORMAT)
6565
end
6666

6767
# Attempt inflating a string, if it fails, return the original string.
6868
# @param data [String] The string
69+
# @param max_bytesize [Integer] The maximum allowed size of the SAML Message,
70+
# to prevent a possible DoS attack.
6971
# @return [String] The inflated or original string
70-
def try_inflate(data)
71-
inflate(data)
72+
def try_inflate(data, max_bytesize = nil)
73+
inflate(data, max_bytesize)
7274
rescue Zlib::Error
7375
data
7476
end
7577

7678
# Inflate method.
7779
# @param deflated [String] The string
80+
# @param max_bytesize [Integer] The maximum allowed size of the SAML Message,
81+
# to prevent a possible DoS attack.
7882
# @return [String] The inflated string
79-
def inflate(deflated)
80-
Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(deflated)
83+
def inflate(deflated, max_bytesize = nil)
84+
if max_bytesize.nil?
85+
Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(deflated)
86+
else
87+
inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
88+
89+
# Use a StringIO buffer to build the inflated message incrementally.
90+
buffer = StringIO.new
91+
92+
inflater.inflate(deflated) do |chunk|
93+
if buffer.length + chunk.bytesize > max_bytesize
94+
inflater.close
95+
raise ValidationError.new("SAML Message exceeds #{max_bytesize} bytes during decompression, so was rejected")
96+
end
97+
buffer << chunk
98+
end
99+
100+
final_chunk = inflater.finish
101+
unless final_chunk.empty?
102+
raise ValidationError.new("SAML Message exceeds #{max_bytesize} bytes during decompression, so was rejected") if buffer.length + final_chunk.bytesize > max_bytesize
103+
104+
buffer << final_chunk
105+
end
106+
107+
inflater.close
108+
buffer.string
109+
end
81110
end
82111

83112
# Deflate method.

lib/ruby_saml/xml/decryptor.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ module Decryptor
1212
# @return [Nokogiri::XML::Document] The SAML document with assertions decrypted
1313
def decrypt_document(document, decryption_keys)
1414
# Copy the document
15-
document = RubySaml::XML.safe_load_nokogiri(document.to_s)
15+
document = RubySaml::XML.safe_load_xml(document.to_s, check_malformed_doc: true)
1616
validate_decryption_keys!(decryption_keys)
1717

1818
response_node = document.at_xpath(

lib/ruby_saml/xml/document_signer.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ module DocumentSigner
2727
# <Object />
2828
# </Signature>
2929
def sign_document(document, private_key, certificate, signature_method = RubySaml::XML::RSA_SHA256, digest_method = RubySaml::XML::SHA256)
30-
noko = RubySaml::XML.safe_load_nokogiri(document.to_s)
30+
noko = RubySaml::XML.safe_load_xml(document.to_s, check_malformed_doc: true)
3131

3232
sign_document!(noko, private_key, certificate, signature_method, digest_method)
3333
end

lib/ruby_saml/xml/signed_document_info.rb

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,13 @@ class SignedDocumentInfo
1414
# @param check_malformed_doc [Boolean] Whether to check for malformed documents
1515
def initialize(noko, check_malformed_doc: true)
1616
noko = if noko.is_a?(Nokogiri::XML::Document)
17-
RubySaml::XML.copy_nokogiri(noko)
17+
RubySaml::XML.copy_xml(noko)
1818
else
19-
RubySaml::XML.safe_load_nokogiri(noko, check_malformed_doc: check_malformed_doc)
19+
begin
20+
@document = RubySaml::XML.safe_load_xml(noko, check_malformed_doc: check_malformed_doc)
21+
rescue StandardError => e
22+
raise ValidationError.new("XML load failed: #{e.message}") if e.message != "Empty document"
23+
end
2024
end
2125
@noko = noko
2226
@check_malformed_doc = check_malformed_doc

0 commit comments

Comments
 (0)