sinatra-bouncer 1.3.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -4,16 +4,26 @@ require_relative 'rule'
4
4
 
5
5
  module Sinatra
6
6
  module Bouncer
7
+ # Core implementation of Bouncer logic
7
8
  class BasicBouncer
8
9
  attr_accessor :bounce_with, :rules_initializer
9
10
 
10
- 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
11
+ # Enumeration of HTTP method strings based on:
12
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
13
+ # Ignoring CONNECT and TRACE due to rarity
14
+ HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH].freeze
15
+
16
+ # Symbol versions of HTTP_METHODS constant
17
+ #
18
+ # @see HTTP_METHODS
19
+ HTTP_METHOD_SYMBOLS = HTTP_METHODS.collect do |http_method|
20
+ http_method.downcase.to_sym
21
+ end.freeze
22
+
23
+ # Method symbol used to match any method
24
+ WILDCARD_METHOD = :any
16
25
 
26
+ def initialize
17
27
  @ruleset = Hash.new do
18
28
  []
19
29
  end
@@ -21,37 +31,45 @@ module Sinatra
21
31
  @rules_initializer = proc {}
22
32
  end
23
33
 
24
- def reset!
25
- @ruleset.clear
26
- end
27
-
28
- def can(method, *paths)
34
+ def can(**method_routes)
29
35
  if block_given?
30
36
  hint = 'If you wish to conditionally allow, use #can_sometimes instead.'
31
37
  raise BouncerError, "You cannot provide a block to #can. #{ hint }"
32
38
  end
33
39
 
34
- can_sometimes(method, *paths) do
40
+ can_sometimes(**method_routes) do
35
41
  true
36
42
  end
37
43
  end
38
44
 
39
- def can_sometimes(method, *paths, &block)
45
+ def can_sometimes(**method_routes, &block)
40
46
  unless block
41
47
  hint = 'If you wish to always allow, use #can instead.'
42
48
  raise BouncerError, "You must provide a block to #can_sometimes. #{ hint }"
43
49
  end
44
50
 
45
- paths.each do |path|
46
- @ruleset[method] += [Rule.new(path, &block)]
51
+ method_routes.each do |method, paths|
52
+ unless HTTP_METHOD_SYMBOLS.include?(method) || method == WILDCARD_METHOD
53
+ hint = "Must be one of: #{ HTTP_METHOD_SYMBOLS } or :#{ WILDCARD_METHOD }"
54
+ raise BouncerError, "'#{ method }' is not a known HTTP method key. #{ hint }"
55
+ end
56
+
57
+ paths = [paths] unless paths.respond_to? :collect
58
+
59
+ @ruleset[method] += paths.collect { |path| Rule.new(path, &block) }
47
60
  end
48
61
  end
49
62
 
50
- def can?(method, path)
51
- rules = (@ruleset[:any_method] + @ruleset[method]).select { |rule| rule.match_path?(path) }
63
+ def can?(method, path, context)
64
+ rulesets = @ruleset[WILDCARD_METHOD] + @ruleset[method]
65
+
66
+ # HEAD requests are equivalent to GET requests without response
67
+ rulesets += @ruleset[:get] if method == :head
68
+
69
+ rules = rulesets.select { |rule| rule.match_path?(path) }
52
70
 
53
71
  rules.any? do |rule_block|
54
- ruling = rule_block.rule_passes?
72
+ ruling = rule_block.rule_passes? context
55
73
 
56
74
  ruling
57
75
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  module Sinatra
4
4
  module Bouncer
5
+ # Defines a Rule to be evaluated with each request
5
6
  class Rule
6
7
  def initialize(path, &rule_block)
7
8
  if path == :all
@@ -15,6 +16,9 @@ module Sinatra
15
16
  @rule = rule_block
16
17
  end
17
18
 
19
+ # Determines if the path matches the exact path or wildcard.
20
+ #
21
+ # @return `true` if the path matches
18
22
  def match_path?(path)
19
23
  return true if @path == :all
20
24
 
@@ -33,10 +37,14 @@ module Sinatra
33
37
  matches
34
38
  end
35
39
 
36
- def rule_passes?
37
- ruling = @rule.call
40
+ # Evaluates the rule's block. Defensively prevents truthy values from the block from allowing a route.
41
+ #
42
+ # @raise BouncerError when the rule block is a truthy value but not exactly `true`
43
+ # @return Exactly `true` or `false`, depending on the result of the rule block
44
+ def rule_passes?(context)
45
+ ruling = context.instance_exec(&@rule)
38
46
 
39
- unless ruling.is_a?(TrueClass) || ruling.is_a?(FalseClass)
47
+ unless !ruling || ruling.is_a?(TrueClass)
40
48
  source = @rule.source_location.join(':')
41
49
  msg = <<~ERR
42
50
  Rule block at does not return explicit true/false.
@@ -47,7 +55,7 @@ module Sinatra
47
55
  raise BouncerError, msg
48
56
  end
49
57
 
50
- ruling
58
+ !!ruling
51
59
  end
52
60
  end
53
61
  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 = '2.0.0'
7
7
  end
8
8
  end
@@ -23,52 +23,32 @@
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
37
  http_method = request.request_method.downcase.to_sym
44
38
  path = request.path.downcase
45
39
 
46
- bouncer.bounce(self) unless bouncer.can?(http_method, path)
40
+ settings.bouncer.bounce(self) unless settings.bouncer.can?(http_method, path, self)
47
41
  end
48
42
  end
49
43
 
50
- # Start ExtensionMethods
51
44
  def bounce_with(&block)
52
45
  bouncer.bounce_with = block
53
46
  end
54
47
 
55
48
  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(...)
67
- end
49
+ settings.bouncer.instance_exec(&block)
68
50
  end
69
51
  end
70
52
 
71
- if defined? register
72
- register Bouncer
73
- end
53
+ register Sinatra::Bouncer
74
54
  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: 2.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-13 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
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- src_dir = File.expand_path('../../..', __dir__)
4
- $LOAD_PATH.unshift(src_dir) unless $LOAD_PATH.include?(src_dir)
5
-
6
- require 'simplecov'
7
-
8
- SimpleCov.command_name 'spec'
9
-
10
- require 'capybara/cucumber'
11
- require 'rspec/expectations'
12
-
13
- require 'tests/test_app'
14
-
15
- # == CAPYBARA ==
16
- Capybara.app = Sinatra::Application
17
-
18
- # Set this to whatever the server's normal port is for you. Sinatra is 4567; rack 9292 by default.
19
- # Also note: you have to be running the server for this to work.
20
- Capybara.asset_host = 'http://localhost:4567'
21
-
22
- # == REGULAR SETTINGS ==
23
- Before do
24
- Capybara.reset_sessions!
25
- Capybara.app.settings.bouncer.instance_variable_get(:@ruleset).clear
26
-
27
- @allowed_once_paths = []
28
- end
29
-
30
- World(RSpec::Matchers)
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Helper methods for Cucumber steps
4
- module HelperMethods
5
- # TEST_TMP_ROOT = Pathname.new(Dir.mktmpdir('bouncer_test_')).expand_path.freeze
6
- # TEST_TMP_LOG = (TEST_TMP_ROOT / 'log').expand_path.freeze
7
-
8
- # Data conversion helpers for Cucumber steps
9
- module Conversion
10
- def extract_list(list_string)
11
- (list_string || '').split(',').map(&:strip)
12
- end
13
-
14
- def symrow(table)
15
- table.symbolic_hashes.first
16
- end
17
-
18
- def symtable(table)
19
- table.map_headers do |header|
20
- header.tr(' ', '_').downcase.to_sym
21
- end
22
- end
23
-
24
- def parse_bool(string)
25
- !(string =~ /t|y/i).nil?
26
- end
27
-
28
- def parse_phone(string)
29
- string.to_s.split(/:\s+/)
30
- end
31
-
32
- def parse_duration(string)
33
- scalar, unit = string.split(/\s/)
34
-
35
- return nil if unit.nil? || unit.empty?
36
-
37
- unit = "#{ unit }s" unless unit.end_with?('s')
38
-
39
- unit_map = {
40
- years: 365.25 * 86400,
41
- months: 30 * 86400,
42
- weeks: 7 * 86400,
43
- days: 86400,
44
- hours: 3600,
45
- minutes: 60,
46
- seconds: 1
47
- }
48
-
49
- scalar.to_i * unit_map[unit.downcase.to_sym]
50
- end
51
- end
52
- end
53
-
54
- # Inject the HelperMethods into the Cucumber test context
55
- World(HelperMethods, HelperMethods::Conversion)
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- ParameterType(name: 'path',
4
- regexp: %r{/(?:\S+/?)*},
5
- type: Pathname,
6
- transformer: lambda do |str|
7
- Pathname.new(str)
8
- end)
9
-
10
- ParameterType(name: 'boolean',
11
- regexp: /(enabled|disabled|true|false|on|off|yes|no)/,
12
- transformer: lambda do |str|
13
- %w[enabled true on yes].include? str.downcase
14
- end)
15
-
16
- ParameterType(name: 'html element',
17
- regexp: /<(\S+)>/,
18
- type: String,
19
- transformer: lambda do |str|
20
- str
21
- end)
@@ -1,148 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative 'spec_helper'
4
-
5
- describe Sinatra::Bouncer::BasicBouncer do
6
- let(:bouncer) { Sinatra::Bouncer::BasicBouncer.new }
7
-
8
- describe '#can' do
9
- it 'should raise an error if provided a block' do
10
- msg = <<~ERR
11
- You cannot provide a block to #can. If you wish to conditionally allow, use #can_sometimes instead.
12
- ERR
13
-
14
- expect do
15
- bouncer.can(:post, 'some_path') do
16
- # stub
17
- end
18
- end.to raise_error(Sinatra::Bouncer::BouncerError, msg.chomp)
19
- end
20
-
21
- it 'should handle a list of paths' do
22
- bouncer.can(:post, 'some_path', 'other_path')
23
-
24
- expect(bouncer.can?(:post, 'some_path')).to be true
25
- expect(bouncer.can?(:post, 'other_path')).to be true
26
- end
27
-
28
- it 'should accept a splat' do
29
- bouncer.can(:post, 'directory/*')
30
-
31
- expect(bouncer.can?(:post, 'directory/some_path')).to be true
32
- end
33
- end
34
-
35
- describe '#can_sometimes' do
36
- it 'should accept :any_method to mean all http methods' do
37
- bouncer.can_sometimes(:any_method, 'some_path') do
38
- true
39
- end
40
-
41
- expect(bouncer.can?(:get, 'some_path')).to be true
42
- expect(bouncer.can?(:post, 'some_path')).to be true
43
- expect(bouncer.can?(:put, 'some_path')).to be true
44
- expect(bouncer.can?(:delete, 'some_path')).to be true
45
- expect(bouncer.can?(:options, 'some_path')).to be true
46
- expect(bouncer.can?(:link, 'some_path')).to be true
47
- expect(bouncer.can?(:unlink, 'some_path')).to be true
48
- expect(bouncer.can?(:head, 'some_path')).to be true
49
- expect(bouncer.can?(:trace, 'some_path')).to be true
50
- expect(bouncer.can?(:connect, 'some_path')).to be true
51
- expect(bouncer.can?(:patch, 'some_path')).to be true
52
- end
53
-
54
- it 'should accept :all to mean all paths' do
55
- bouncer.can_sometimes(:get, :all) do
56
- true
57
- end
58
-
59
- expect(bouncer.can?(:get, 'some_path')).to be true
60
- end
61
-
62
- it 'should accept a list of paths' do
63
- bouncer.can_sometimes(:post, 'some_path', 'other_path') do
64
- true
65
- end
66
-
67
- expect(bouncer.can?(:post, 'some_path')).to be true
68
- expect(bouncer.can?(:post, 'other_path')).to be true
69
- end
70
-
71
- it 'should accept a splat' do
72
- bouncer.can_sometimes(:post, 'directory/*') do
73
- true
74
- end
75
-
76
- expect(bouncer.can?(:post, 'directory/some_path')).to be true
77
- end
78
-
79
- it 'should not raise an error if provided a block' do
80
- expect do
81
- bouncer.can_sometimes(:any_method, 'some_path') do
82
- true
83
- end
84
- end.to_not raise_error
85
- end
86
-
87
- it 'should raise an error if not provided a block' do
88
- msg = <<~ERR
89
- You must provide a block to #can_sometimes. If you wish to always allow, use #can instead.
90
- ERR
91
-
92
- expect do
93
- bouncer.can_sometimes(:any_method, 'some_path')
94
- end.to raise_error(Sinatra::Bouncer::BouncerError, msg.chomp)
95
- end
96
- end
97
-
98
- describe '#can?' do
99
- it 'should pass when declared allowed' do
100
- bouncer.can(:any_method, 'some_path')
101
-
102
- expect(bouncer.can?(:post, 'some_path')).to be true
103
- end
104
-
105
- it 'should fail when not declared allowed' do
106
- expect(bouncer.can?(:post, 'some_path')).to be false
107
- end
108
-
109
- it 'should pass if the rule block passes' do
110
- bouncer.can_sometimes(:any_method, 'some_path') do
111
- true
112
- end
113
-
114
- expect(bouncer.can?(:post, 'some_path')).to be true
115
- end
116
-
117
- it 'should fail if the rule block fails' do
118
- bouncer.can_sometimes(:any_method, 'some_path') do
119
- false
120
- end
121
-
122
- expect(bouncer.can?(:post, 'some_path')).to be false
123
- end
124
- end
125
-
126
- describe '#bounce' do
127
- it 'should run the bounce_with block on sinatra instance' do
128
- runner = nil
129
- sinatra = double('sinatra')
130
-
131
- bouncer.bounce_with = proc do
132
- runner = self # self should be the sinatra double
133
- end
134
-
135
- bouncer.bounce(sinatra)
136
-
137
- expect(runner).to be sinatra
138
- end
139
-
140
- it 'should halt 403 if no block provided' do
141
- app = double('sinatra')
142
-
143
- expect(app).to receive(:halt).with(403)
144
-
145
- bouncer.bounce(app)
146
- end
147
- end
148
- end