sinatra-bouncer 1.3.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/RELEASE_NOTES.md ADDED
@@ -0,0 +1,170 @@
1
+ # Release Notes
2
+
3
+ All notable changes to this project will be documented below.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project loosely follows
6
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Major Changes
11
+
12
+ * none
13
+
14
+ ### Minor Changes
15
+
16
+ * none
17
+
18
+ ### Bugfixes
19
+
20
+ * none
21
+
22
+ ## [3.0.0] - 2023-11-21
23
+
24
+ ### Major Changes
25
+
26
+ * Changed rules DSL to be role-oriented
27
+ * Removed the `:any` HTTP method wildcard
28
+ * Changed Sinatra API to require calling methods on bouncer object
29
+ * eg. `rules do ... end` should now be `bouncer.rules do ... end`
30
+
31
+ ### Minor Changes
32
+
33
+ * none
34
+
35
+ ### Bugfixes
36
+
37
+ * none
38
+
39
+ ## [2.0.0] - 2023-11-13
40
+
41
+ ### Major Changes
42
+
43
+ * Converted to hash syntax in `can` and `can_sometimes` statements
44
+
45
+ ### Minor Changes
46
+
47
+ * Converted Cucumber tests to Rspec integration tests for consistency
48
+ * HEAD requests are evaluated like GET requests due to semantic equivalence
49
+ * Falsey values are now acceptable rule block results
50
+
51
+ ### Bugfixes
52
+
53
+ * none
54
+
55
+ ## [1.3.0] - 2023-09-13
56
+
57
+ ### Major Changes
58
+
59
+ * none
60
+
61
+ ### Minor Changes
62
+
63
+ * Increased minimum Ruby to 3.1
64
+ * Cleaned up development dependencies
65
+ * Rubocop cleanups
66
+ * Installed Simplecov
67
+ * Created Rakefile
68
+
69
+ ### Bugfixes
70
+
71
+ * none
72
+
73
+ ## [1.2.0] - 2016-10-02
74
+
75
+ ### Major Changes
76
+
77
+ * none
78
+
79
+ ### Minor Changes
80
+
81
+ * Supports wildcard matches in route strings
82
+
83
+ ### Bugfixes
84
+
85
+ * none
86
+
87
+ ## [1.1.1] - 2015-06-02
88
+
89
+ ### Major Changes
90
+
91
+ * none
92
+
93
+ ### Minor Changes
94
+
95
+ * none
96
+
97
+ ### Bugfixes
98
+
99
+ * Runs `bounce_with` in context of web request
100
+
101
+ ## [1.1.0] - 2015-05-28
102
+
103
+ ### Major Changes
104
+
105
+ * none
106
+
107
+ ### Minor Changes
108
+
109
+ * none
110
+
111
+ ### Bugfixes
112
+
113
+ * Correctly forgets rules between routes
114
+
115
+ ## [1.0.2] - 2015-05-24
116
+
117
+ ### Major Changes
118
+
119
+ * none
120
+
121
+ ### Minor Changes
122
+
123
+ * Changed default halt to 403
124
+ * Application available in rule block
125
+
126
+ ### Bugfixes
127
+
128
+ * Fixed sinatra module registration
129
+
130
+ ## [1.0.1] - 2015-05-21
131
+
132
+ ### Major Changes
133
+
134
+ * none
135
+
136
+ ### Minor Changes
137
+
138
+ * none
139
+
140
+ ### Bugfixes
141
+
142
+ * none
143
+
144
+ ## [1.0.0] - Unreleased Prototype
145
+
146
+ ### Major Changes
147
+
148
+ * none
149
+
150
+ ### Minor Changes
151
+
152
+ * none
153
+
154
+ ### Bugfixes
155
+
156
+ * none
157
+
158
+ ## [0.1.0] - Unreleased Prototype
159
+
160
+ ### Major Changes
161
+
162
+ * Initial prototype
163
+
164
+ ### Minor Changes
165
+
166
+ * none
167
+
168
+ ### Bugfixes
169
+
170
+ * none
@@ -4,65 +4,62 @@ require_relative 'rule'
4
4
 
5
5
  module Sinatra
6
6
  module Bouncer
7
+ # Core implementation of Bouncer logic
7
8
  class BasicBouncer
8
- attr_accessor :bounce_with, :rules_initializer
9
+ attr_accessor :rules_initializer
9
10
 
10
11
  def initialize
11
- # @rules = Hash.new do |method_to_paths, method|
12
- # method_to_paths[method] = Hash.new do |path_to_rules, path|
13
- # path_to_rules[path] = []
14
- # end
15
- # end
12
+ @rules = []
16
13
 
17
- @ruleset = Hash.new do
18
- []
14
+ role :anyone do
15
+ true
19
16
  end
20
17
 
21
18
  @rules_initializer = proc {}
19
+ @bounce_strategy = proc do
20
+ halt 403
21
+ end
22
22
  end
23
23
 
24
- def reset!
25
- @ruleset.clear
24
+ def rules(&block)
25
+ instance_exec(&block)
26
+ @rules.each do |rule|
27
+ raise BouncerError, 'rules block error: missing #can or #can_sometimes call' if rule.incomplete?
28
+ end
26
29
  end
27
30
 
28
- def can(method, *paths)
29
- if block_given?
30
- hint = 'If you wish to conditionally allow, use #can_sometimes instead.'
31
- raise BouncerError, "You cannot provide a block to #can. #{ hint }"
32
- end
31
+ def can?(method, path, context)
32
+ method = method.downcase.to_sym unless method.is_a? Symbol
33
33
 
34
- can_sometimes(method, *paths) do
35
- true
34
+ @rules.any? do |rule|
35
+ rule.allow? method, path, context
36
36
  end
37
37
  end
38
38
 
39
- def can_sometimes(method, *paths, &block)
40
- unless block
41
- hint = 'If you wish to always allow, use #can instead.'
42
- raise BouncerError, "You must provide a block to #can_sometimes. #{ hint }"
43
- end
44
-
45
- paths.each do |path|
46
- @ruleset[method] += [Rule.new(path, &block)]
47
- end
39
+ def bounce_with(&block)
40
+ @bounce_strategy = block
48
41
  end
49
42
 
50
- def can?(method, path)
51
- rules = (@ruleset[:any_method] + @ruleset[method]).select { |rule| rule.match_path?(path) }
43
+ def bounce(instance)
44
+ instance.instance_exec(&@bounce_strategy)
45
+ end
52
46
 
53
- rules.any? do |rule_block|
54
- ruling = rule_block.rule_passes?
47
+ def role(identifier, &block)
48
+ raise ArgumentError, 'must provide a role identifier to #role' unless identifier
49
+ raise ArgumentError, 'must provide a role condition block to #role' unless block
50
+ raise ArgumentError, "role called '#{ identifier }' already defined" if respond_to? identifier
55
51
 
56
- ruling
52
+ define_singleton_method identifier do
53
+ add_rule(&block)
57
54
  end
58
55
  end
59
56
 
60
- def bounce(instance)
61
- if bounce_with
62
- instance.instance_exec(&bounce_with)
63
- else
64
- instance.halt 403
65
- end
57
+ private
58
+
59
+ def add_rule(&block)
60
+ rule = Rule.new(&block)
61
+ @rules << rule
62
+ rule
66
63
  end
67
64
  end
68
65
 
@@ -2,42 +2,122 @@
2
2
 
3
3
  module Sinatra
4
4
  module Bouncer
5
+ # Defines a RuleSet to be evaluated with each request
5
6
  class Rule
6
- def initialize(path, &rule_block)
7
- if path == :all
8
- @path = :all
9
- else
10
- path = "/#{ path }" unless path.start_with?('/')
7
+ # Enumeration of HTTP method strings based on:
8
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
9
+ # Ignoring CONNECT and TRACE due to rarity
10
+ HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH].freeze
11
+
12
+ # Symbol versions of HTTP_METHODS constant
13
+ #
14
+ # @see HTTP_METHODS
15
+ HTTP_METHOD_SYMBOLS = HTTP_METHODS.collect do |http_method|
16
+ http_method.downcase.to_sym
17
+ end.freeze
11
18
 
12
- @path = path.split('/')
19
+ def initialize(&block)
20
+ raise ArgumentError, 'must provide a block to Bouncer::Rule' unless block
21
+
22
+ @routes = Hash.new do
23
+ []
13
24
  end
14
25
 
15
- @rule = rule_block
26
+ @conditions = [block]
16
27
  end
17
28
 
18
- def match_path?(path)
19
- return true if @path == :all
29
+ def can_sometimes(**method_routes, &block)
30
+ if method_routes.empty?
31
+ raise ArgumentError,
32
+ 'must provide a hash where keys are HTTP method symbols and values are one or more path matchers'
33
+ end
20
34
 
21
- path = "/#{ path }" unless path.start_with?('/')
35
+ unless block
36
+ hint = 'If you wish to always allow, use #can instead.'
37
+ raise BouncerError, "You must provide a block to #can_sometimes. #{ hint }"
38
+ end
22
39
 
23
- split_path = path.split('/')
24
- matches = @path.length == split_path.length
40
+ method_routes.each do |method, paths|
41
+ validate_http_method! method
25
42
 
26
- @path.each_index do |i|
27
- allowed_segment = @path[i]
28
- given_segment = split_path[i]
43
+ paths = [paths] unless paths.respond_to? :collect
29
44
 
30
- matches &= given_segment == allowed_segment || allowed_segment == '*'
45
+ @routes[method] += paths.collect { |path| normalize_path path }
31
46
  end
32
47
 
33
- matches
48
+ @conditions << block
34
49
  end
35
50
 
36
- def rule_passes?
37
- ruling = @rule.call
51
+ def can(**method_routes)
52
+ if block_given?
53
+ hint = 'If you wish to conditionally allow, use #can_sometimes instead.'
54
+ raise BouncerError, "You cannot provide a block to #can. #{ hint }"
55
+ end
56
+
57
+ can_sometimes(**method_routes) do
58
+ true
59
+ end
60
+ end
61
+
62
+ def allow?(method, path, context)
63
+ match_path?(method, path) && @conditions.all? do |condition|
64
+ rule_passes?(context, &condition)
65
+ end
66
+ end
67
+
68
+ def incomplete?
69
+ @routes.empty?
70
+ end
71
+
72
+ private
73
+
74
+ def validate_http_method!(method)
75
+ return if HTTP_METHOD_SYMBOLS.include?(method)
76
+
77
+ raise BouncerError, "'#{ method }' is not a known HTTP method key. Must be one of: #{ HTTP_METHOD_SYMBOLS }"
78
+ end
38
79
 
39
- unless ruling.is_a?(TrueClass) || ruling.is_a?(FalseClass)
40
- source = @rule.source_location.join(':')
80
+ # Determines if the path matches the exact path or wildcard.
81
+ #
82
+ # @return `true` if the path matches
83
+ def match_path?(method, trial_path)
84
+ trial_path = normalize_path trial_path
85
+
86
+ matchers_for(method).any? do |matcher|
87
+ return true if matcher == :all
88
+
89
+ matcher_parts = matcher.split '/'
90
+ trial_parts = trial_path.split '/'
91
+ matches = matcher_parts.length == trial_parts.length
92
+
93
+ matcher_parts.each_index do |i|
94
+ allowed_segment = matcher_parts[i]
95
+ given_segment = trial_parts[i]
96
+
97
+ matches &= given_segment == allowed_segment || allowed_segment == '*'
98
+ end
99
+
100
+ matches
101
+ end
102
+ end
103
+
104
+ def matchers_for(method)
105
+ matchers = @routes[method]
106
+
107
+ matchers += @routes[:get] if method == :head
108
+
109
+ matchers
110
+ end
111
+
112
+ # Evaluates the rule's block. Defensively prevents truthy values from the block from allowing a route.
113
+ #
114
+ # @raise BouncerError when the rule block is a truthy value but not exactly `true`
115
+ # @return Exactly `true` or `false`, depending on the result of the rule block
116
+ def rule_passes?(context, &rule)
117
+ ruling = context.instance_exec(&rule)
118
+
119
+ unless !ruling || ruling.is_a?(TrueClass)
120
+ source = rule.source_location.join(':')
41
121
  msg = <<~ERR
42
122
  Rule block at does not return explicit true/false.
43
123
  Rules must return explicit true or false to prevent accidental truthy values.
@@ -47,7 +127,19 @@ module Sinatra
47
127
  raise BouncerError, msg
48
128
  end
49
129
 
50
- ruling
130
+ !!ruling
131
+ end
132
+
133
+ def normalize_path(path)
134
+ if path == :all
135
+ path
136
+ else
137
+ if path.start_with?('/')
138
+ path
139
+ else
140
+ "/#{ path }"
141
+ end.downcase
142
+ end
51
143
  end
52
144
  end
53
145
  end
@@ -3,6 +3,6 @@
3
3
  module Sinatra
4
4
  module Bouncer
5
5
  # Current version of the gem
6
- VERSION = '1.3.0'
6
+ VERSION = '3.0.0'
7
7
  end
8
8
  end
@@ -23,52 +23,21 @@
23
23
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24
24
  #++
25
25
 
26
+ require 'sinatra/base'
26
27
  require_relative 'bouncer/basic_bouncer'
27
28
 
29
+ # Namespace module
28
30
  module Sinatra
31
+ # Namespace module
29
32
  module Bouncer
30
33
  def self.registered(base_class)
31
- base_class.helpers HelperMethods
32
-
33
- bouncer = BasicBouncer.new
34
-
35
- # TODO: can we instead store it somehow on the actual temp request object?
36
- base_class.set :bouncer, bouncer
34
+ base_class.set :bouncer, BasicBouncer.new
37
35
 
38
36
  base_class.before do
39
- bouncer.reset! # must clear all rules otherwise will leave doors open
40
-
41
- instance_exec(&bouncer.rules_initializer)
42
-
43
- http_method = request.request_method.downcase.to_sym
44
- path = request.path.downcase
45
-
46
- bouncer.bounce(self) unless bouncer.can?(http_method, path)
47
- end
48
- end
49
-
50
- # Start ExtensionMethods
51
- def bounce_with(&block)
52
- bouncer.bounce_with = block
53
- end
54
-
55
- def rules(&block)
56
- bouncer.rules_initializer = block
57
- end
58
- # End ExtensionMethods
59
-
60
- module HelperMethods
61
- def can(*args)
62
- settings.bouncer.can(*args)
63
- end
64
-
65
- def can_sometimes(...)
66
- settings.bouncer.can_sometimes(...)
37
+ settings.bouncer.bounce(self) unless settings.bouncer.can?(request.request_method, request.path, self)
67
38
  end
68
39
  end
69
40
  end
70
41
 
71
- if defined? register
72
- register Bouncer
73
- end
42
+ register Sinatra::Bouncer
74
43
  end
@@ -19,7 +19,10 @@ Gem::Specification.new do |spec|
19
19
  'rubygems_mfa_required' => 'true'
20
20
  }
21
21
 
22
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|integrations)/}) }
22
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
23
+ f.match(%r{^(test|spec|features|integrations|benchmarks)/})
24
+ end
25
+
23
26
  spec.require_paths = ['lib']
24
27
 
25
28
  spec.required_ruby_version = '>= 3.1'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinatra-bouncer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tenjin Inc
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-09-13 00:00:00.000000000 Z
12
+ date: 2023-11-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: sinatra
@@ -37,9 +37,12 @@ files:
37
37
  - ".rubocop.yml"
38
38
  - ".ruby-version"
39
39
  - ".simplecov"
40
+ - CODE_OF_CONDUCT.md
40
41
  - Gemfile
42
+ - Gemfile.lock
41
43
  - MIT-LICENSE
42
44
  - README.md
45
+ - RELEASE_NOTES.md
43
46
  - Rakefile
44
47
  - cucumber.yml
45
48
  - lib/sinatra/bouncer.rb
@@ -47,18 +50,6 @@ files:
47
50
  - lib/sinatra/bouncer/rule.rb
48
51
  - lib/sinatra/bouncer/version.rb
49
52
  - sinatra_bouncer.gemspec
50
- - tests/integrations/dev_defines_legal_routes.feature
51
- - tests/integrations/dev_installs_bouncer.feature
52
- - tests/integrations/step_definitions/given.rb
53
- - tests/integrations/step_definitions/then.rb
54
- - tests/integrations/step_definitions/when.rb
55
- - tests/integrations/support/env.rb
56
- - tests/integrations/support/helpers.rb
57
- - tests/integrations/support/types.rb
58
- - tests/spec/basic_bouncer_spec.rb
59
- - tests/spec/rule_spec.rb
60
- - tests/spec/spec_helper.rb
61
- - tests/test_app.rb
62
53
  homepage: https://github.com/TenjinInc/Sinatra-Bouncer
63
54
  licenses:
64
55
  - MIT
@@ -1,57 +0,0 @@
1
- Feature: Developer defines legal routes
2
- As a developer
3
- So that clients can safely access my server
4
- I will allow specific routes
5
-
6
- Scenario Outline: it should allows access to whitelist routes
7
- Given a sinatra server with bouncer and routes:
8
- | method | path | allowed |
9
- | get | <path> | yes |
10
- When I visit /<path>
11
- Then it should be at /<path>
12
- And it should have status code 200
13
- Examples:
14
- | path |
15
- | some_path |
16
- | another_path |
17
-
18
- Scenario: it should NOT allow access to other routes
19
- Given a sinatra server with bouncer and routes:
20
- | method | path | allowed |
21
- | get | some_path | yes |
22
- | get | illegal_path | no |
23
- When I visit /illegal_path
24
- Then it should have status code 403
25
-
26
- Scenario Outline: it should allow multiple routes with a splat
27
- Given a sinatra server with bouncer and routes:
28
- | method | path | allowed |
29
- | get | admin/* | yes |
30
- When I visit /admin/<sub_path>
31
- Then it should be at /admin/<sub_path>
32
- And it should have status code 200
33
- Examples:
34
- | sub_path |
35
- | dashboard |
36
- | users |
37
-
38
- Scenario Outline: it should allow splat to be in the middle of the route
39
- Given a sinatra server with bouncer and routes:
40
- | method | path | allowed |
41
- | get | admin/*/create | yes |
42
- When I visit /admin/<sub_path>/create
43
- Then it should be at /admin/<sub_path>/create
44
- And it should have status code 200
45
- Examples:
46
- | sub_path |
47
- | tasks |
48
- | users |
49
-
50
- Scenario: it should forget rules between requests
51
- Given a sinatra server with bouncer and routes:
52
- | method | path | allowed |
53
- | get | some_path | once |
54
- When I double visit /some_path
55
- Then it should be at /some_path
56
- And it should have status code 403
57
-
@@ -1,12 +0,0 @@
1
- Feature: Developer installs Bouncer
2
- As a developer
3
- So that I can secure my sinatra server
4
- I will install bouncer
5
-
6
- Scenario: it should auto protect all routes
7
- Given a sinatra server with bouncer and routes:
8
- | method | path |
9
- | get | some_path |
10
- When I visit /some_path
11
- Then it should have status code 403
12
-
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Given 'a sinatra server with bouncer and routes:' do |table|
4
- app = Capybara.app
5
-
6
- allowed_paths = []
7
-
8
- table.hashes.each do |row|
9
- path = row[:path]
10
- path = "/#{ path }" if path[0] != '/' # ensure leading slash
11
-
12
- method = row[:method].to_sym
13
- # build the routes
14
- app.send(method, path) do
15
- 'The result of path'
16
- end
17
-
18
- is_once = row[:allowed] =~ /once/i
19
-
20
- next unless is_once || parse_bool(row[:allowed])
21
-
22
- allowed_paths << path
23
-
24
- @allowed_once_paths << path if is_once
25
- end
26
-
27
- onces = @allowed_once_paths
28
-
29
- app.rules do
30
- allowed_paths.each do |path|
31
- can_sometimes(:any_method, path) do
32
- !onces.include?(path)
33
- end
34
- end
35
- end
36
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Then 'it should have status code {int}' do |status|
4
- expect(page.driver.status_code).to eq status
5
- end
6
-
7
- Then 'it should be at {path}' do |path|
8
- expect(page).to have_current_path path.to_s
9
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- When 'he/she/they/I double visit(s) {path}' do |path|
4
- visit path
5
- visit '/'
6
- visit path
7
- end
8
-
9
- When 'he/she/they/I visit(s) {path}' do |path|
10
- visit path
11
- end