Skip to content

Commit d974a84

Browse files
authored
Merge pull request #245 from gerardo-navarro/gerardo-navarro-implement-slo-relay-state-validator
Refine SLO RelayState validation
2 parents ecd23a7 + c01f6a4 commit d974a84

File tree

3 files changed

+224
-23
lines changed

3 files changed

+224
-23
lines changed

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,19 @@ Note that when [integrating with Devise](#devise-integration), the URL path will
101101
* `:slo_default_relay_state` - The value to use as default `RelayState` for single log outs. The
102102
value can be a string, or a `Proc` (or other object responding to `call`). The `request`
103103
instance will be passed to this callable if it has an arity of 1. If the value is a string,
104-
the string will be returned, when the `RelayState` is called. Optional.
104+
the string will be returned, when the `RelayState` is called.
105+
The value is assumed to be safe and is not validated by `:slo_relay_state_validator`.
106+
Optional.
105107

106108
* `:slo_enabled` - Enables or disables Single Logout (SLO). Set to `false` to disable SLO. Defaults to `true`. Optional.
107109

110+
* `:slo_relay_state_validator` - A callable used to validate any `RelayState` before performing the redirect
111+
in Single Logout flows. The callable receives the RelayState value and the current Rack request.
112+
If unset, the default validator is used. The default validator allows only relative paths beginning
113+
with `/` and rejects absolute URLs, invalid URIs, protocol-relative URLs, and other schemes.
114+
If the given `RelayState` is considered invalid then the `slo_default_relay_state` value is used for the SLO redirect.
115+
Optional.
116+
108117
* `:idp_sso_service_url_runtime_params` - A dynamic mapping of request params that exist
109118
during the request phase of OmniAuth that should to be sent to the IdP after a specific
110119
mapping. So for example, a param `original_request_param` with value `original_param_value`,

lib/omniauth/strategies/saml.rb

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
require 'omniauth'
22
require 'ruby-saml'
3+
require 'uri'
34

45
module OmniAuth
56
module Strategies
@@ -27,8 +28,26 @@ def self.inherited(subclass)
2728
first_name: ["first_name", "firstname", "firstName"],
2829
last_name: ["last_name", "lastname", "lastName"]
2930
}
31+
DEFAULT_SLO_RELAY_STATE_VALIDATOR = lambda do |relay_state, _request|
32+
return true if relay_state.nil? || relay_state == ""
33+
34+
return false if relay_state.start_with?("//")
35+
36+
begin
37+
uri = URI.parse(relay_state)
38+
rescue URI::Error
39+
return false
40+
end
41+
42+
return false unless uri.relative?
43+
44+
path = uri.path
45+
path && path.start_with?("/")
46+
end
47+
3048
option :slo_default_relay_state
3149
option :slo_enabled, true
50+
option :slo_relay_state_validator, DEFAULT_SLO_RELAY_STATE_VALIDATOR
3251
option :uid_attribute
3352
option :idp_slo_session_destroy, proc { |_env, session| session.clear }
3453

@@ -148,19 +167,34 @@ def handle_response(raw_response, opts, settings)
148167

149168
def slo_relay_state
150169
if request.params.has_key?("RelayState") && request.params["RelayState"] != ""
151-
request.params["RelayState"]
152-
else
153-
slo_default_relay_state = options.slo_default_relay_state
154-
if slo_default_relay_state.respond_to?(:call)
155-
if slo_default_relay_state.arity == 1
156-
slo_default_relay_state.call(request)
157-
else
158-
slo_default_relay_state.call
159-
end
160-
else
161-
slo_default_relay_state
162-
end
170+
relay_state = request.params["RelayState"]
171+
172+
return relay_state if valid_slo_relay_state?(relay_state)
163173
end
174+
175+
default_slo_relay_state
176+
end
177+
178+
def valid_slo_relay_state?(relay_state)
179+
validator = options.slo_relay_state_validator
180+
181+
return !!call_slo_relay_state_validator(validator, relay_state) if validator.respond_to?(:call)
182+
183+
!!validator
184+
end
185+
186+
def call_slo_relay_state_validator(validator, relay_state)
187+
return validator.call if validator.arity.zero?
188+
return validator.call(relay_state) if validator.arity == 1
189+
validator.call(relay_state, request)
190+
end
191+
192+
def default_slo_relay_state
193+
slo_default_relay_state = options.slo_default_relay_state
194+
195+
return slo_default_relay_state unless slo_default_relay_state.respond_to?(:call)
196+
return slo_default_relay_state.call if slo_default_relay_state.arity.zero?
197+
slo_default_relay_state.call(request)
164198
end
165199

166200
def handle_logout_response(raw_response, settings)

spec/omniauth/strategies/saml_spec.rb

Lines changed: 168 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,55 @@
3030
end
3131
let(:strategy) { [OmniAuth::Strategies::SAML, saml_options] }
3232

33+
shared_examples 'validating RelayState param' do
34+
context 'when slo_relay_state_validator is not defined and default' do
35+
[
36+
['/signed-out', '//attacker.test', '%2Fsigned-out'],
37+
['/signed-out', 'javascript:alert(1)', '%2Fsigned-out'],
38+
['/signed-out', 'https://example.com/logout', '%2Fsigned-out'],
39+
['/signed-out', 'https://example.com/logout?param=1&two=two', '%2Fsigned-out'],
40+
['/signed-out', '/', '%2F'],
41+
['', '//attacker.test', ''],
42+
['', '/team/logout', '%2Fteam%2Flogout'],
43+
].each do |slo_default_relay_state, relay_state_param, expected_relay_state|
44+
context "when slo_default_relay_state: #{slo_default_relay_state.inspect}, relay_state_param: #{relay_state_param.inspect}" do
45+
let(:saml_options) { super().merge(slo_default_relay_state: slo_default_relay_state) }
46+
let(:params) { super().merge('RelayState' => relay_state_param) }
47+
48+
it { is_expected.to be_redirect.and have_attributes(location: a_string_including("RelayState=#{expected_relay_state}")) }
49+
end
50+
end
51+
end
52+
53+
context 'when slo_relay_state_validator is overridden' do
54+
[
55+
['/signed-out', proc { |state| state.start_with?('https://trusted.example.com') }, 'https://trusted.example.com/logout', 'https%3A%2F%2Ftrusted.example.com%2Flogout'],
56+
['/signed-out', proc { |state| state.start_with?('https://trusted.example.com') }, 'https://attacker.test/logout', '%2Fsigned-out'],
57+
['/signed-out', proc { |state| state.start_with?('https://trusted.example.com') }, '/safe/path', '%2Fsigned-out'],
58+
['/signed-out', proc { |state, req| state == req.params['RelayState'] }, '/team/logout', '%2Fteam%2Flogout'],
59+
['/signed-out', nil, '//attacker.test', '%2Fsigned-out'],
60+
['/signed-out', false, '//attacker.test', '%2Fsigned-out'],
61+
['/signed-out', proc { |_| false }, '//attacker.test', '%2Fsigned-out'],
62+
['/signed-out', proc { |_| true }, 'javascript:alert(1)', 'javascript%3Aalert%281%29'],
63+
[nil, true, 'https://example.com/logout', 'https%3A%2F%2Fexample.com%2Flogout'],
64+
[nil, true, 'javascript:alert(1)', 'javascript%3Aalert%281%29'],
65+
[nil, true, '/', '%2F'],
66+
].each do |slo_default_relay_state, slo_relay_state_validator, relay_state_param, expected_relay_state|
67+
context "when slo_default_relay_state: #{slo_default_relay_state.inspect}, slo_relay_state_validator: #{slo_relay_state_validator.inspect}, relay_state_param: #{relay_state_param.inspect}" do
68+
let(:saml_options) do
69+
super().merge(
70+
slo_default_relay_state: slo_default_relay_state,
71+
slo_relay_state_validator: slo_relay_state_validator,
72+
)
73+
end
74+
let(:params) { super().merge('RelayState' => relay_state_param) }
75+
76+
it { is_expected.to be_redirect.and have_attributes(location: a_string_including("RelayState=#{expected_relay_state}")) }
77+
end
78+
end
79+
end
80+
end
81+
3382
describe 'POST /auth/saml' do
3483
context 'without idp runtime params present' do
3584
before do
@@ -274,37 +323,128 @@
274323
end
275324

276325
context "when response is a logout response" do
277-
before :each do
278-
post "/auth/saml/slo", {
279-
SAMLResponse: load_xml(:example_logout_response),
280-
RelayState: "https://example.com/",
281-
}, "rack.session" => {"saml_transaction_id" => "_3fef1069-d0c6-418a-b68d-6f008a4787e9"}
326+
let(:opts) do
327+
{ "rack.session" => { "saml_transaction_id" => "_3fef1069-d0c6-418a-b68d-6f008a4787e9" } }
282328
end
283329

284-
it "should redirect to relaystate" do
285-
expect(last_response).to be_redirect
286-
expect(last_response.location).to match /https:\/\/example.com\//
330+
let(:params) { { SAMLResponse: load_xml(:example_logout_response) } }
331+
332+
subject(:post_slo_response) { post "/auth/saml/slo", params, opts }
333+
334+
context "when relay state is relative" do
335+
let(:params) { super().merge(RelayState: "/signed-out") }
336+
337+
it "redirects to the relaystate" do
338+
post_slo_response
339+
340+
expect(last_response).to be_redirect
341+
expect(last_response.location).to eq "/signed-out"
342+
end
343+
end
344+
345+
context "when relay state is an absolute https URL" do
346+
let(:params) { super().merge(RelayState: "https://example.com/") }
347+
348+
it "redirects without a location header" do
349+
post_slo_response
350+
351+
expect(last_response).to be_redirect
352+
expect(last_response.headers.fetch("Location")).to be_nil
353+
end
354+
end
355+
356+
context 'when slo_default_relay_state is present' do
357+
let(:saml_options) { super().merge(slo_default_relay_state: '/signed-out') }
358+
359+
context "when response relay state is valid" do
360+
let(:params) { super().merge(RelayState: "/safe/logout") }
361+
362+
it { is_expected.to be_redirect.and have_attributes(location: '/safe/logout') }
363+
end
364+
365+
context "when response relay state is invalid" do
366+
let(:params) { super().merge(RelayState: "javascript:alert(1)") }
367+
368+
it { is_expected.to be_redirect.and have_attributes(location: '/signed-out') }
369+
end
370+
end
371+
372+
context 'when slo_default_relay_state is blank' do
373+
let(:saml_options) { super().merge(slo_default_relay_state: nil) }
374+
375+
context "when response relay state is valid" do
376+
let(:params) { super().merge(RelayState: "/safe/logout") }
377+
378+
it { is_expected.to be_redirect.and have_attributes(location: '/safe/logout') }
379+
end
380+
381+
context "when response relay state is invalid" do
382+
let(:params) { super().merge(RelayState: "javascript:alert(1)") }
383+
384+
it { is_expected.to be_redirect.and have_attributes(location: nil) }
385+
end
287386
end
288387
end
289388

290389
context "when request is a logout request" do
291390
subject { post "/auth/saml/slo", params, "rack.session" => { "saml_uid" => "[email protected]" } }
292391

392+
let(:relay_state) { "https://example.com/" }
393+
293394
let(:params) do
294395
{
295396
"SAMLRequest" => load_xml(:example_logout_request),
296-
"RelayState" => "https://example.com/",
397+
"RelayState" => relay_state,
297398
}
298399
end
299400

300401
context "when logout request is valid" do
402+
let(:relay_state) { "/logout" }
403+
301404
before { subject }
302405

303406
it "should redirect to logout response" do
304407
expect(last_response).to be_redirect
305408
expect(last_response.location).to match /https:\/\/idp.sso.example.com\/signoff\/29490/
306-
expect(last_response.location).to match /RelayState=https%3A%2F%2Fexample.com%2F/
409+
expect(last_response.location).to match /RelayState=%2Flogout/
410+
end
411+
end
412+
413+
it_behaves_like 'validating RelayState param'
414+
415+
context 'when slo_default_relay_state is blank' do
416+
let(:saml_options) { super().merge(slo_default_relay_state: nil) }
417+
418+
context "when request relay state is invalid" do
419+
let(:params) do
420+
{
421+
"SAMLRequest" => load_xml(:example_logout_request),
422+
"RelayState" => "javascript:alert(1)",
423+
}
424+
end
425+
426+
it "redirects without including a RelayState parameter" do
427+
subject
428+
429+
expect(last_response).to be_redirect
430+
expect(last_response.location).to match %r{https://idp\.sso\.example\.com/signoff/29490}
431+
expect(last_response.location).not_to match(/RelayState=/)
432+
end
433+
end
434+
end
435+
436+
context "with a custom relay state validator" do
437+
let(:saml_options) do
438+
super().merge(
439+
slo_relay_state_validator: proc do |relay_state, rack_request|
440+
expect(rack_request).to respond_to(:params)
441+
relay_state == "custom-state"
442+
end,
443+
)
307444
end
445+
let(:params) { super().merge("RelayState" => "custom-state") }
446+
447+
it { is_expected.to be_redirect.and have_attributes(location: a_string_matching(/RelayState=custom-state/)) }
308448
end
309449

310450
context "when request is an invalid logout request" do
@@ -345,6 +485,9 @@
345485
end
346486

347487
describe 'POST /auth/saml/spslo' do
488+
let(:params) { {} }
489+
subject { post "/auth/saml/spslo", params }
490+
348491
def test_default_relay_state(static_default_relay_state = nil, &block_default_relay_state)
349492
saml_options["slo_default_relay_state"] = static_default_relay_state || block_default_relay_state
350493
post "/auth/saml/spslo"
@@ -370,6 +513,21 @@ def test_default_relay_state(static_default_relay_state = nil, &block_default_re
370513
end
371514
end
372515

516+
it_behaves_like 'validating RelayState param'
517+
518+
context 'when slo_default_relay_state is blank' do
519+
let(:saml_options) { super().merge(slo_default_relay_state: nil) }
520+
let(:params) { { RelayState: "//example.com" } }
521+
522+
it "redirects without including a RelayState parameter" do
523+
subject
524+
525+
expect(last_response).to be_redirect
526+
expect(last_response.location).to match %r{https://idp\.sso\.example\.com/signoff/29490}
527+
expect(last_response.location).not_to match(/RelayState=/)
528+
end
529+
end
530+
373531
it "should give not implemented without an idp_slo_service_url" do
374532
saml_options.delete(:idp_slo_service_url)
375533
post "/auth/saml/spslo"

0 commit comments

Comments
 (0)