sinatra-bouncer 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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