diff --git a/README.md b/README.md index e8410f4..9fcef78 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,36 @@ config.middleware.use Rack::SslEnforcer, :except_methods => ['GET', 'HEAD'] Note: The `:hosts` constraint takes precedence over the `:path` constraint. Please see the tests for examples. +### Method + Path combination constraints + +You can enforce SSL connections only for certain HTTP method and path combinations with `:only_methods_with_paths`, +or prevent enforcement of SSL connections for certain HTTP method and path combinations with `:except_methods_with_paths`. +The combination constraint must be a `Hash` with methods as keys and paths as values. +Method constraints can be a `Symbol`, a `String` or an array of `Symbol` or `String`. +Path constraints can be a `String`, a `Regex` or an array of `String` or `Regex`. + +Examples: + +```ruby +# Enforce SSL on all requests except HEAD requests to anything under /users or /widgets +config.middleware.use Rack::SslEnforcer, :except_methods_with_paths => {'HEAD' => ['/users', '/widgets']} + +# Enforce SSL only on POST and PUT requests to anything under /admin +config.middleware.use Rack::SslEnforcer, :only_methods_with_paths => {['POST', 'PUT'] => '/admin'} + +# Enforce SSL only on POST, PUT, and PATCH requests to anything under /admin +# and POST, PUT, and PATCH requests to any path matching `/users/` +config.middleware.use Rack::SslEnforcer, :only_methods_with_paths => {[:post, :put, :patch] => ['/admin', /users/]} + +# Multiple combination constraints... you get the idea +config.middleware.use Rack::SslEnforcer, :only_methods_with_paths => { + :post => ['/admin', /users/], + [:put, :patch] => /users/, + :get => '/secrets' +} +``` + + ### Environment constraints You can enforce SSL connections only for certain environments with `:only_environments` or prevent certain environments from being forced to SSL with `:except_environments`. diff --git a/lib/rack/ssl-enforcer.rb b/lib/rack/ssl-enforcer.rb index c4d6b26..bfa2ccc 100644 --- a/lib/rack/ssl-enforcer.rb +++ b/lib/rack/ssl-enforcer.rb @@ -5,10 +5,11 @@ module Rack class SslEnforcer CONSTRAINTS_BY_TYPE = { - :hosts => [:only_hosts, :except_hosts], - :path => [:only, :except], - :methods => [:only_methods, :except_methods], - :environments => [:only_environments, :except_environments] + :hosts => [:only_hosts, :except_hosts], + :path => [:only, :except], + :methods => [:only_methods, :except_methods], + :methods_with_paths => [:only_methods_with_paths, :except_methods_with_paths], + :environments => [:only_environments, :except_environments] } # Warning: If you set the option force_secure_cookies to false, make sure that your cookies @@ -113,18 +114,64 @@ def current_scheme end end - def enforce_ssl_for?(keys) - provided_keys = keys.select { |key| @options[key] } - if provided_keys.empty? - true - else - provided_keys.all? do |key| - rules = [@options[key]].flatten.compact - rules.send([:except_hosts, :except_environments, :except].include?(key) ? :all? : :any?) do |rule| - SslEnforcerConstraint.new(key, rule, @request).matches? + def enforce_ssl_for?(constraints) + + # Need to divide constraints into the following groups and combine the results as: + # (methodpath_combo_matches || methodpath_discrete_matches) && other_discrete_matches + methodpath_combo_constraints = constraints.select do |constraint| + constraint.to_s =~ /methods_with_paths$/ ? true : false + end + methodpath_discrete_constraints = constraints.select do |constraint| + constraint.to_s =~ /(only$|except$|methods$)/ ? true : false + end + other_discrete_constraints = constraints.reject do |constraint| + constraint.to_s =~ /(only$|except$|methods)/ ? true : false + end + + # For methodpath_combo_constraints, match method and path rules separately, then combine the results. + methodpath_combo_matches = methodpath_combo_constraints.any? do |constraint| + constraint_type = constraint.to_s[0, constraint.to_s.index('_') || constraint.to_s.length] + + @options[constraint].send(constraint_type == 'except' ? :all? : :any?) do |method_rules, path_rules| + + method_rules = [method_rules].flatten.compact + method_constraint = "#{constraint_type}_methods".to_sym + method_matches = method_rules.send(constraint_type == 'except' ? :all? : :any?) do |method_rule| + SslEnforcerConstraint.new(method_constraint, method_rule, @request).matches? + end + + path_rules = [path_rules].flatten.compact + path_constraint = constraint_type.to_sym + path_matches = path_rules.send(constraint_type == 'except' ? :all? : :any?) do |path_rule| + SslEnforcerConstraint.new(path_constraint, path_rule, @request).matches? + end + + constraint_type == 'except' ? method_matches || path_matches : method_matches && path_matches + end + end + + # Use the same logic for both methodpath_discrete_constraints and other_discrete_constraints. + matches = [] + [methodpath_discrete_constraints, other_discrete_constraints].each do |discrete_constraints| + matches << discrete_constraints.all? do |constraint| + constraint_type = constraint.to_s[0, constraint.to_s.index('_') || constraint.to_s.length] + + rules = [@options[constraint]].flatten.compact + rules.send(constraint_type == 'except' ? :all? : :any?) do |rule| + SslEnforcerConstraint.new(constraint, rule, @request).matches? end end end + methodpath_discrete_matches, other_discrete_matches = matches + + # Bring it all together. + if methodpath_combo_constraints.empty? + methodpath_discrete_matches && other_discrete_matches + elsif methodpath_discrete_constraints.empty? + methodpath_combo_matches && other_discrete_matches + else + (methodpath_combo_matches || methodpath_discrete_matches) && other_discrete_matches + end end def enforce_non_ssl? @@ -132,8 +179,12 @@ def enforce_non_ssl? end def enforce_ssl? - CONSTRAINTS_BY_TYPE.inject(true) do |memo, (type, keys)| - memo && enforce_ssl_for?(keys) + all_constraints = CONSTRAINTS_BY_TYPE.values.flatten + provided_constraints = all_constraints.select { |constraint| @options[constraint] } + if provided_constraints.empty? + true + else + enforce_ssl_for?(provided_constraints) end end diff --git a/lib/rack/ssl-enforcer/constraint.rb b/lib/rack/ssl-enforcer/constraint.rb index dbde91d..1f332db 100644 --- a/lib/rack/ssl-enforcer/constraint.rb +++ b/lib/rack/ssl-enforcer/constraint.rb @@ -1,7 +1,7 @@ class SslEnforcerConstraint def initialize(name, rule, request) @name = name - @rule = rule + @rule = name.to_s =~ /methods$/ && (rule.is_a?(String) || rule.is_a?(Symbol)) ? rule.to_s.upcase : rule @request = request end @@ -18,7 +18,7 @@ def matches? private def negate_result? - @name.to_s =~ /except/ + @name.to_s =~ /^except/ end def operator @@ -27,11 +27,11 @@ def operator def tested_string case @name.to_s - when /hosts/ + when /hosts$/ @request.host - when /methods/ + when /methods$/ @request.request_method - when /environments/ + when /environments$/ ENV["RACK_ENV"] || ENV["RAILS_ENV"] || ENV["ENV"] else @request.path diff --git a/test/rack-ssl-enforcer_test.rb b/test/rack-ssl-enforcer_test.rb index 7954079..e1bd106 100644 --- a/test/rack-ssl-enforcer_test.rb +++ b/test/rack-ssl-enforcer_test.rb @@ -904,6 +904,128 @@ class TestRackSslEnforcer < Test::Unit::TestCase end end + context ':only_methods_with_paths' do + setup { mock_app :only_methods_with_paths => { + [:post, :put] => '/admin', + :get => ['/secrets', /users/] + } } + + should 'redirect to HTTPS for POST /admin/account' do + post 'http://www.example.org/admin/account' + assert_equal 301, last_response.status + assert_equal 'https://www.example.org/admin/account', last_response.location + end + + should 'redirect to HTTPS for PUT /admin/account' do + put 'http://www.example.org/admin/account' + assert_equal 301, last_response.status + assert_equal 'https://www.example.org/admin/account', last_response.location + end + + should 'redirect to HTTPS for GET /secrets' do + get 'http://www.example.org/secrets' + assert_equal 301, last_response.status + assert_equal 'https://www.example.org/secrets', last_response.location + end + + should 'redirect to HTTPS for GET /foo/users/bar' do + get 'http://www.example.org/foo/users/bar' + assert_equal 301, last_response.status + assert_equal 'https://www.example.org/foo/users/bar', last_response.location + end + + should 'not redirect for GET /admin/account' do + get 'http://www.example.org/admin/account' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + + should 'not redirect for POST /secrets' do + post 'http://www.example.org/secrets' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + + should 'not redirect for POST /foo/users/bar' do + post 'http://www.example.org/foo/users/bar' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + end + + context ':except_methods_with_paths' do + setup { mock_app :except_methods_with_paths => { + [:post, :get] => '/public', + :get => ['/admin', /users/] + } } + + should 'not redirect for POST /public/stuff' do + post 'http://www.example.org/public/stuff' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + + should 'not redirect for GET /public/stuff' do + get 'http://www.example.org/public/stuff' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + + should 'not redirect for GET /admin/account' do + get 'http://www.example.org/admin/account' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + + should 'not redirect for GET /foo/users/bar' do + get 'http://www.example.org/foo/users/bar' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + + should 'redirect to HTTPS for POST /foobar' do + post 'http://www.example.org/foobar' + assert_equal 301, last_response.status + assert_equal 'https://www.example.org/foobar', last_response.location + end + + should 'redirect to HTTPS for GET /foobar' do + get 'http://www.example.org/foobar' + assert_equal 301, last_response.status + assert_equal 'https://www.example.org/foobar', last_response.location + end + end + + context 'complex example using :only, :only_methods, and :only_methods_with_paths' do + setup { mock_app :only => '/admin', :only_methods => 'POST', + :only_methods_with_paths => { :get => /secrets/ } + } + + should 'redirect to HTTPS for POST /admin/account' do + post 'http://www.example.org/admin/account' + assert_equal 301, last_response.status + assert_equal 'https://www.example.org/admin/account', last_response.location + end + + should 'redirect to HTTPS for GET /magical/secrets' do + get 'http://www.example.org/magical/secrets' + assert_equal 301, last_response.status + assert_equal 'https://www.example.org/magical/secrets', last_response.location + end + + should 'not redirect for GET /admin/account' do + get 'http://www.example.org/admin/account' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + + should 'not redirect for POST /magical/secrets' do + post 'http://www.example.org/magical/secrets' + assert_equal 200, last_response.status + assert_equal 'Hello world!', last_response.body + end + end + context 'complex example' do setup { mock_app :only => '/cart', :ignore => %r{/assets}, :strict => true }