diff --git a/rack-protection/README.md b/rack-protection/README.md index df344115..6f6df7b8 100644 --- a/rack-protection/README.md +++ b/rack-protection/README.md @@ -73,6 +73,11 @@ Prevented by: * `Rack::Protection::SessionHijacking` +## Cookie Tossing + +Prevented by: +* `Rack::Protection::CookieTossing` (not included by `use Rack::Protection`) + ## IP Spoofing Prevented by: diff --git a/rack-protection/lib/rack/protection.rb b/rack-protection/lib/rack/protection.rb index 7b4ff682..7565a9d2 100644 --- a/rack-protection/lib/rack/protection.rb +++ b/rack-protection/lib/rack/protection.rb @@ -5,6 +5,7 @@ module Rack module Protection autoload :AuthenticityToken, 'rack/protection/authenticity_token' autoload :Base, 'rack/protection/base' + autoload :CookieTossing, 'rack/protection/cookie_tossing' autoload :ContentSecurityPolicy, 'rack/protection/content_security_policy' autoload :EscapedParams, 'rack/protection/escaped_params' autoload :FormToken, 'rack/protection/form_token' @@ -31,6 +32,7 @@ module Rack Rack::Builder.new do # Off by default, unless added use ::Rack::Protection::AuthenticityToken, options if use_these.include? :authenticity_token + use ::Rack::Protection::CookieTossing, options if use_these.include? :cookie_tossing use ::Rack::Protection::ContentSecurityPolicy, options if use_these.include? :content_security_policy use ::Rack::Protection::FormToken, options if use_these.include? :form_token use ::Rack::Protection::RemoteReferrer, options if use_these.include? :remote_referrer diff --git a/rack-protection/lib/rack/protection/cookie_tossing.rb b/rack-protection/lib/rack/protection/cookie_tossing.rb new file mode 100644 index 00000000..68461295 --- /dev/null +++ b/rack-protection/lib/rack/protection/cookie_tossing.rb @@ -0,0 +1,73 @@ +require 'rack/protection' +require 'pathname' + +module Rack + module Protection + ## + # Prevented attack:: Cookie Tossing + # Supported browsers:: all + # More infos:: https://github.com/blog/1466-yummy-cookies-across-domains + # + # Does not accept HTTP requests if the HTTP_COOKIE header contains more than one + # session cookie. This does not protect against a cookie overflow attack. + # + # Options: + # + # session_key:: The name of the session cookie (default: 'rack.session') + class CookieTossing < Base + default_reaction :deny + + def call(env) + status, headers, body = super + response = Rack::Response.new(body, status, headers) + request = Rack::Request.new(env) + remove_bad_cookies(request, response) + response.finish + end + + def accepts?(env) + cookie_header = env['HTTP_COOKIE'] + cookies = Rack::Utils.parse_query(cookie_header, ';,') { |s| s } + cookies.each do |k, v| + if k == session_key && Array(v).size > 1 + bad_cookies << k + elsif k != session_key && Rack::Utils.unescape(k) == session_key + bad_cookies << k + end + end + bad_cookies.empty? + end + + def remove_bad_cookies(request, response) + bad_cookies.each do |name| + cookie_paths(request.path).each { |path| response.set_cookie name, empty_cookie(request.host, path) } + end + end + + def redirect(env) + request = Request.new(env) + warn env, "attack prevented by #{self.class}" + [302, {'Content-Type' => 'text/html', 'Location' => request.path}, []] + end + + def bad_cookies + @bad_cookies ||= [] + end + + def cookie_paths(path) + path = '/' if path.to_s.empty? + paths = [] + Pathname.new(path).descend { |p| paths << p.to_s } + paths + end + + def empty_cookie(host, path) + {:value => '', :domain => host, :path => path, :expires => Time.at(0)} + end + + def session_key + @session_key ||= options[:session_key] + end + end + end +end diff --git a/rack-protection/spec/lib/rack/protection/cookie_tossing_spec.rb b/rack-protection/spec/lib/rack/protection/cookie_tossing_spec.rb new file mode 100644 index 00000000..af46ffc2 --- /dev/null +++ b/rack-protection/spec/lib/rack/protection/cookie_tossing_spec.rb @@ -0,0 +1,74 @@ +describe Rack::Protection::CookieTossing do + it_behaves_like "any rack application" + + context 'with default reaction' do + before(:each) do + mock_app do + use Rack::Protection::CookieTossing + run DummyApp + end + end + + it 'accepts requests with a single session cookie' do + get '/', {}, 'HTTP_COOKIE' => 'rack.session=SESSION_TOKEN' + expect(last_response).to be_ok + end + + it 'denies requests with duplicate session cookies' do + get '/', {}, 'HTTP_COOKIE' => 'rack.session=EVIL_SESSION_TOKEN; rack.session=SESSION_TOKEN' + expect(last_response).not_to be_ok + end + + it 'denies requests with sneaky encoded session cookies' do + get '/', {}, 'HTTP_COOKIE' => 'rack.session=EVIL_SESSION_TOKEN; rack.%73ession=SESSION_TOKEN' + expect(last_response).not_to be_ok + end + + it 'adds the correct Set-Cookie header' do + get '/some/path', {}, 'HTTP_COOKIE' => 'rack.%73ession=EVIL_SESSION_TOKEN; rack.session=EVIL_SESSION_TOKEN; rack.session=SESSION_TOKEN' + + expected_header = <<-END.chomp +rack.%2573ession=; domain=example.org; path=/; expires=Thu, 01 Jan 1970 00:00:00 -0000 +rack.%2573ession=; domain=example.org; path=/some; expires=Thu, 01 Jan 1970 00:00:00 -0000 +rack.%2573ession=; domain=example.org; path=/some/path; expires=Thu, 01 Jan 1970 00:00:00 -0000 +rack.session=; domain=example.org; path=/; expires=Thu, 01 Jan 1970 00:00:00 -0000 +rack.session=; domain=example.org; path=/some; expires=Thu, 01 Jan 1970 00:00:00 -0000 +rack.session=; domain=example.org; path=/some/path; expires=Thu, 01 Jan 1970 00:00:00 -0000 +END + expect(last_response.headers['Set-Cookie']).to eq(expected_header) + end + end + + context 'with redirect reaction' do + before(:each) do + mock_app do + use Rack::Protection::CookieTossing, :reaction => :redirect + run DummyApp + end + end + + it 'redirects requests with duplicate session cookies' do + get '/', {}, 'HTTP_COOKIE' => 'rack.session=EVIL_SESSION_TOKEN; rack.session=SESSION_TOKEN' + expect(last_response).to be_redirect + expect(last_response.location).to eq('/') + end + + it 'redirects requests with sneaky encoded session cookies' do + get '/path', {}, 'HTTP_COOKIE' => 'rack.%73ession=EVIL_SESSION_TOKEN; rack.session=SESSION_TOKEN' + expect(last_response).to be_redirect + expect(last_response.location).to eq('/path') + end + end + + context 'with custom session key' do + it 'denies requests with duplicate session cookies' do + mock_app do + use Rack::Protection::CookieTossing, :session_key => '_session' + run DummyApp + end + + get '/', {}, 'HTTP_COOKIE' => '_session=EVIL_SESSION_TOKEN; _session=SESSION_TOKEN' + expect(last_response).not_to be_ok + end + end +end