Skip to content

Commit 12d5064

Browse files
authored
Merge pull request #627 from visfleet/force-escape-downcasing
Force escape downcasing for Azure SLO
2 parents 2b2170d + 70e6544 commit 12d5064

File tree

5 files changed

+70
-5
lines changed

5 files changed

+70
-5
lines changed

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ However, ruby-saml never enables this dangerous Nokogiri configuration;
6666
ruby-saml never enables DTDLOAD, and it never disables NONET.
6767

6868
The OneLogin::RubySaml::IdpMetadataParser class does not validate in any way the URL
69-
that is introduced in order to be parsed.
69+
that is introduced in order to be parsed.
7070

7171
Usually the same administrator that handles the Service Provider also sets the URL to
7272
the IdP, which should be a trusted resource.
@@ -790,7 +790,13 @@ Here is an example that we could add to our previous controller to process a SAM
790790
# Method to handle IdP initiated logouts
791791
def idp_logout_request
792792
settings = Account.get_saml_settings
793-
logout_request = OneLogin::RubySaml::SloLogoutrequest.new(params[:SAMLRequest])
793+
# ADFS URL-Encodes SAML data as lowercase, and the toolkit by default uses
794+
# uppercase. Turn it True for ADFS compatibility on signature verification
795+
settings.security[:lowercase_url_encoding] = true
796+
797+
logout_request = OneLogin::RubySaml::SloLogoutrequest.new(
798+
params[:SAMLRequest], settings: settings
799+
)
794800
if !logout_request.is_valid?
795801
logger.error "IdP initiated LogoutRequest was not valid!"
796802
return render :inline => logger.error

lib/onelogin/ruby-saml/settings.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ def get_binding(value)
282282
:check_idp_cert_expiration => false,
283283
:check_sp_cert_expiration => false,
284284
:strict_audience_validation => false,
285+
:lowercase_url_encoding => false
285286
}.freeze
286287
}.freeze
287288
end

lib/onelogin/ruby-saml/slo_logoutrequest.rb

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,13 +263,13 @@ def validate_signature
263263
# the exact same URI-encoding as the IDP. (This is not the case if the IDP is ADFS!)
264264
options[:raw_get_params] ||= {}
265265
if options[:raw_get_params]['SAMLRequest'].nil? && !options[:get_params]['SAMLRequest'].nil?
266-
options[:raw_get_params]['SAMLRequest'] = CGI.escape(options[:get_params]['SAMLRequest'])
266+
options[:raw_get_params]['SAMLRequest'] = escape_request_param(options[:get_params]['SAMLRequest'])
267267
end
268268
if options[:raw_get_params]['RelayState'].nil? && !options[:get_params]['RelayState'].nil?
269-
options[:raw_get_params]['RelayState'] = CGI.escape(options[:get_params]['RelayState'])
269+
options[:raw_get_params]['RelayState'] = escape_request_param(options[:get_params]['RelayState'])
270270
end
271271
if options[:raw_get_params]['SigAlg'].nil? && !options[:get_params]['SigAlg'].nil?
272-
options[:raw_get_params]['SigAlg'] = CGI.escape(options[:get_params]['SigAlg'])
272+
options[:raw_get_params]['SigAlg'] = escape_request_param(options[:get_params]['SigAlg'])
273273
end
274274

275275
# If we only received the raw version of SigAlg,
@@ -336,6 +336,13 @@ def validate_signature
336336
true
337337
end
338338

339+
def escape_request_param(param)
340+
CGI.escape(param).tap do |escaped|
341+
next unless settings.security[:lowercase_url_encoding]
342+
343+
escaped.gsub!(/%[A-Fa-f0-9]{2}/) { |match| match.downcase }
344+
end
345+
end
339346
end
340347
end
341348
end

test/slo_logoutrequest_test.rb

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,52 @@ class RubySamlTest < Minitest::Test
425425
logout_request_sign_test = OneLogin::RubySaml::SloLogoutrequest.new(params['SAMLRequest'], options)
426426
assert logout_request_sign_test.send(:validate_signature)
427427
end
428+
429+
it "handles Azure AD downcased request encoding" do
430+
# Use Logoutrequest only to build the SAMLRequest parameter.
431+
settings.security[:signature_method] = XMLSecurity::Document::RSA_SHA256
432+
settings.soft = false
433+
434+
# Creating the query manually to tweak it later instead of using
435+
# OneLogin::RubySaml::Utils.build_query
436+
request_doc = OneLogin::RubySaml::Logoutrequest.new.create_logout_request_xml_doc(settings)
437+
request = Zlib::Deflate.deflate(request_doc.to_s, 9)[2..-5]
438+
base64_request = Base64.encode64(request).gsub(/\n/, "")
439+
# The original request received from Azure AD comes with downcased
440+
# encoded characters, like %2f instead of %2F, and the signature they
441+
# send is based on this base64 request.
442+
params = {
443+
'SAMLRequest' => downcased_escape(base64_request),
444+
'SigAlg' => downcased_escape(settings.security[:signature_method]),
445+
}
446+
# Assemble query string.
447+
query = "SAMLRequest=#{params['SAMLRequest']}&SigAlg=#{params['SigAlg']}"
448+
# Make normalised signature based on our modified params.
449+
sign_algorithm = XMLSecurity::BaseDocument.new.algorithm(
450+
settings.security[:signature_method]
451+
)
452+
signature = settings.get_sp_key.sign(sign_algorithm.new, query)
453+
params['Signature'] = downcased_escape(Base64.encode64(signature).gsub(/\n/, ""))
454+
455+
# Then parameters are usually unescaped, like we manage them in rails
456+
params = params.map { |k, v| [k, CGI.unescape(v)] }.to_h
457+
# Construct SloLogoutrequest and ask it to validate the signature.
458+
# It will fail because the signature is based on the downcased request
459+
logout_request_downcased_test = OneLogin::RubySaml::SloLogoutrequest.new(
460+
params['SAMLRequest'], get_params: params, settings: settings,
461+
)
462+
assert_raises(OneLogin::RubySaml::ValidationError, "Invalid Signature on Logout Request") do
463+
logout_request_downcased_test.send(:validate_signature)
464+
end
465+
466+
# For this case, the parameters will be forced to be downcased after
467+
# being escaped with :lowercase_url_encoding security option
468+
settings.security[:lowercase_url_encoding] = true
469+
logout_request_force_downcasing_test = OneLogin::RubySaml::SloLogoutrequest.new(
470+
params['SAMLRequest'], get_params: params, settings: settings
471+
)
472+
assert logout_request_force_downcasing_test.send(:validate_signature)
473+
end
428474
end
429475

430476
describe "#validate_signature with multiple idp certs" do

test/test_helper.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,4 +346,9 @@ def validate_xml!(document, schema)
346346
end
347347
end
348348
end
349+
350+
# Allows to emulate Azure AD request behavior
351+
def downcased_escape(str)
352+
CGI.escape(str).gsub(/%[A-Fa-f0-9]{2}/) { |match| match.downcase }
353+
end
349354
end

0 commit comments

Comments
 (0)