Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Method + Path combination constraints #53

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
81 changes: 66 additions & 15 deletions lib/rack/ssl-enforcer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -113,27 +114,77 @@ 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?
@options[:strict] || @options[:mixed] && !(@request.request_method == 'PUT' || @request.request_method == 'POST')
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

Expand Down
10 changes: 5 additions & 5 deletions lib/rack/ssl-enforcer/constraint.rb
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -18,7 +18,7 @@ def matches?
private

def negate_result?
@name.to_s =~ /except/
@name.to_s =~ /^except/
end

def operator
Expand All @@ -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
Expand Down
122 changes: 122 additions & 0 deletions test/rack-ssl-enforcer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down