hanami-controller 2.0.0.alpha1 → 2.0.0.alpha2

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.
@@ -0,0 +1,214 @@
1
+ require "hanami/utils/blank"
2
+ require "rack/utils"
3
+ require "securerandom"
4
+
5
+ module Hanami
6
+ # @api private
7
+ class Action
8
+ # Invalid CSRF Token
9
+ #
10
+ # @since 0.4.0
11
+ class InvalidCSRFTokenError < ::StandardError
12
+ end
13
+
14
+ # CSRF Protection
15
+ #
16
+ # This security mechanism is enabled automatically if sessions are turned on.
17
+ #
18
+ # It stores a "challenge" token in session. For each "state changing request"
19
+ # (eg. <tt>POST</tt>, <tt>PATCH</tt> etc..), we should send a special param:
20
+ # <tt>_csrf_token</tt>.
21
+ #
22
+ # If the param matches with the challenge token, the flow can continue.
23
+ # Otherwise the application detects an attack attempt, it reset the session
24
+ # and <tt>Hanami::Action::InvalidCSRFTokenError</tt> is raised.
25
+ #
26
+ # We can specify a custom handling strategy, by overriding <tt>#handle_invalid_csrf_token</tt>.
27
+ #
28
+ # Form helper (<tt>#form_for</tt>) automatically sets a hidden field with the
29
+ # correct token. A special view method (<tt>#csrf_token</tt>) is available in
30
+ # case the form markup is manually crafted.
31
+ #
32
+ # We can disable this check on action basis, by overriding <tt>#verify_csrf_token?</tt>.
33
+ #
34
+ # @since 0.4.0
35
+ #
36
+ # @see https://www.owasp.org/index.php/Cross-Site_Request_Forgery_%28CSRF%29
37
+ # @see https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
38
+ #
39
+ # @example Custom Handling
40
+ # module Web::Controllers::Books
41
+ # class Create
42
+ # include Web::Action
43
+ #
44
+ # def call(params)
45
+ # # ...
46
+ # end
47
+ #
48
+ # private
49
+ #
50
+ # def handle_invalid_csrf_token
51
+ # Web::Logger.warn "CSRF attack: expected #{ session[:_csrf_token] }, was #{ params[:_csrf_token] }"
52
+ # # manual handling
53
+ # end
54
+ # end
55
+ # end
56
+ #
57
+ # @example Bypass Security Check
58
+ # module Web::Controllers::Books
59
+ # class Create
60
+ # include Web::Action
61
+ #
62
+ # def call(params)
63
+ # # ...
64
+ # end
65
+ #
66
+ # private
67
+ #
68
+ # def verify_csrf_token?
69
+ # false
70
+ # end
71
+ # end
72
+ # end
73
+ module CSRFProtection
74
+ # Session and params key for CSRF token.
75
+ #
76
+ # This key is shared with <tt>hanami-controller</tt> and <tt>hanami-helpers</tt>
77
+ #
78
+ # @since 0.4.0
79
+ # @api private
80
+ CSRF_TOKEN = :_csrf_token
81
+
82
+ # Idempotent HTTP methods
83
+ #
84
+ # By default, the check isn't performed if the request method is included
85
+ # in this list.
86
+ #
87
+ # @since 0.4.0
88
+ # @api private
89
+ IDEMPOTENT_HTTP_METHODS = Hash[
90
+ "GET" => true,
91
+ "HEAD" => true,
92
+ "TRACE" => true,
93
+ "OPTIONS" => true
94
+ ].freeze
95
+
96
+ # @since 0.4.0
97
+ # @api private
98
+ def self.included(action)
99
+ action.class_eval do
100
+ before :set_csrf_token, :verify_csrf_token
101
+ end unless Hanami.respond_to?(:env?) && Hanami.env?(:test)
102
+ end
103
+
104
+ private
105
+
106
+ # Set CSRF Token in session
107
+ #
108
+ # @since 0.4.0
109
+ # @api private
110
+ def set_csrf_token(req, res)
111
+ res.session[CSRF_TOKEN] ||= generate_csrf_token
112
+ end
113
+
114
+ # Verify if CSRF token from params, matches the one stored in session.
115
+ # If not, it raises an error.
116
+ #
117
+ # Don't override this method.
118
+ #
119
+ # To bypass the security check, please override <tt>#verify_csrf_token?</tt>.
120
+ # For custom handling of an attack, please override <tt>#handle_invalid_csrf_token</tt>.
121
+ #
122
+ # @since 0.4.0
123
+ # @api private
124
+ def verify_csrf_token(req, res)
125
+ handle_invalid_csrf_token(req, res) if invalid_csrf_token?(req, res)
126
+ end
127
+
128
+ # Verify if CSRF token from params, matches the one stored in session.
129
+ #
130
+ # Don't override this method.
131
+ #
132
+ # @since 0.4.0
133
+ # @api private
134
+ def invalid_csrf_token?(req, res)
135
+ return false unless verify_csrf_token?(req, res)
136
+
137
+ missing_csrf_token?(req, res) ||
138
+ !::Rack::Utils.secure_compare(req.session[CSRF_TOKEN], req.params[CSRF_TOKEN])
139
+ end
140
+
141
+ # Verify the CSRF token was passed in params.
142
+ #
143
+ # @api private
144
+ def missing_csrf_token?(req, res)
145
+ Hanami::Utils::Blank.blank?(req.params[CSRF_TOKEN])
146
+ end
147
+
148
+ # Generates a random CSRF Token
149
+ #
150
+ # @since 0.4.0
151
+ # @api private
152
+ def generate_csrf_token
153
+ SecureRandom.hex(32)
154
+ end
155
+
156
+ # Decide if perform the check or not.
157
+ #
158
+ # Override and return <tt>false</tt> if you want to bypass security check.
159
+ #
160
+ # @since 0.4.0
161
+ #
162
+ # @example
163
+ # module Web::Controllers::Books
164
+ # class Create
165
+ # include Web::Action
166
+ #
167
+ # def call(params)
168
+ # # ...
169
+ # end
170
+ #
171
+ # private
172
+ #
173
+ # def verify_csrf_token?
174
+ # false
175
+ # end
176
+ # end
177
+ # end
178
+ def verify_csrf_token?(req, res)
179
+ !IDEMPOTENT_HTTP_METHODS[req.request_method]
180
+ end
181
+
182
+ # Handle CSRF attack.
183
+ #
184
+ # The default policy resets the session and raises an exception.
185
+ #
186
+ # Override this method, for custom handling.
187
+ #
188
+ # @raise [Hanami::Action::InvalidCSRFTokenError]
189
+ #
190
+ # @since 0.4.0
191
+ #
192
+ # @example
193
+ # module Web::Controllers::Books
194
+ # class Create
195
+ # include Web::Action
196
+ #
197
+ # def call(params)
198
+ # # ...
199
+ # end
200
+ #
201
+ # private
202
+ #
203
+ # def handle_invalid_csrf_token
204
+ # # custom invalid CSRF management goes here
205
+ # end
206
+ # end
207
+ # end
208
+ def handle_invalid_csrf_token(req, res)
209
+ res.session.clear
210
+ raise InvalidCSRFTokenError
211
+ end
212
+ end
213
+ end
214
+ end
@@ -1,269 +1,164 @@
1
- require 'hanami/utils/json'
1
+ # frozen_string_literal: true
2
2
 
3
3
  module Hanami
4
4
  class Action
5
- # Container useful to transport data with the HTTP session
6
- # It has a life span of one HTTP request or redirect.
5
+ # A container to transport data with the HTTP session, with a lifespan of
6
+ # just one HTTP request or redirect.
7
+ #
8
+ # Behaves like a hash, returning entries for the current request, except for
9
+ # {#[]=}, which updates the hash for the next request.
10
+ #
11
+ # This implementation is derived from Roda's FlashHash, also released under
12
+ # the MIT Licence:
13
+ #
14
+ # Copyright (c) 2014-2020 Jeremy Evans
15
+ # Copyright (c) 2010-2014 Michel Martens, Damian Janowski and Cyril David
16
+ # Copyright (c) 2008-2009 Christian Neukirchen
7
17
  #
8
18
  # @since 0.3.0
9
- # @api private
19
+ # @api public
10
20
  class Flash
11
- # Session key where the data is stored
12
- #
13
- # @since 0.3.0
14
- # @api private
15
- SESSION_KEY = :__flash
16
-
17
- # Session key where keep data is store for redirect
18
- #
19
- # @since 1.3.0
20
- # @api private
21
- KEPT_KEY = :__kept_key
22
-
23
- # Initialize a new Flash instance
24
- #
25
- # @param session [Rack::Session::Abstract::SessionHash] the session
21
+ # @return [Hash] The flash hash for the next request, written to by {#[]=}.
26
22
  #
27
- # @return [Hanami::Action::Flash] the flash
23
+ # @see #[]=
28
24
  #
29
- # @see http://www.rubydoc.info/gems/rack/Rack/Session/Abstract/SessionHash
30
- # @see Hanami::Action::Rack#session_id
31
- def initialize(session)
32
- @session = session
33
- @keep = false
34
-
35
- session[KEPT_KEY] ||= []
36
- session[SESSION_KEY] = {}
37
- end
38
-
39
- # Set the given value for the given key
40
- #
41
- # @param key [#to_s] the key
42
- # @param value [Object] the value
43
- #
44
- # @since 0.3.0
45
- # @api private
46
- def []=(key, value)
47
- _data[key] = value
48
- end
25
+ # @since 2.0.0
26
+ # @api public
27
+ attr_reader :next
49
28
 
50
- # Get the value associated to the given key, if any
29
+ # Initializes a new flash instance
51
30
  #
52
- # @return [Object,NilClass] the value
31
+ # @param hash [Hash, nil] the flash hash for the current request. nil will become an empty hash.
53
32
  #
54
33
  # @since 0.3.0
55
- # @api private
56
- def [](key)
57
- _data.fetch(key) { search_in_kept_data(key) }
34
+ # @api public
35
+ def initialize(hash = {})
36
+ @flash = hash || {}
37
+ @next = {}
58
38
  end
59
39
 
60
- # Iterates through current request data and kept data
61
- #
62
- # @param blk [Proc]
40
+ # @return [Hash] The flash hash for the current request
63
41
  #
64
- # @since 1.2.0
65
- def each(&blk)
66
- _values.each(&blk)
42
+ # @since 2.0.0
43
+ # @api public
44
+ def now
45
+ @flash
67
46
  end
68
47
 
69
- # Iterates through current request data and kept data
48
+ # Returns the value for the given key in the current hash
70
49
  #
71
- # @param blk [Proc]
72
- # @return [Array]
73
- #
74
- # @since 1.2.0
75
- def map(&blk)
76
- _values.map(&blk)
77
- end
78
-
79
- # Removes entirely the flash from the session if it has stale contents
80
- # or if empty.
50
+ # @param key [Object] the key
81
51
  #
82
- # @return [void]
52
+ # @return [Object, nil] the value
83
53
  #
84
54
  # @since 0.3.0
85
- # @api private
86
- def clear
87
- # FIXME we're just before a release and I can't find a proper way to reproduce
88
- # this bug that I've found via a browser.
89
- #
90
- # It may happen that `#flash` is nil, and those two methods will fail
91
- unless _data.nil?
92
- update_kept_request_count
93
- keep_data if @keep
94
- expire_kept
95
- remove
96
- end
55
+ # @api public
56
+ def [](key)
57
+ @flash[key]
97
58
  end
98
59
 
99
- # Check if there are contents stored in the flash from the current or the
100
- # previous request.
60
+ # Updates the next hash with the given key and value
101
61
  #
102
- # @return [TrueClass,FalseClass] the result of the check
62
+ # @param key [Object] the key
63
+ # @param value [Object] the value
103
64
  #
104
65
  # @since 0.3.0
105
- # @api private
106
- def empty?
107
- _values.empty?
108
- end
109
-
110
- # @return [String]
111
- #
112
- # @since 1.0.0
113
- def inspect
114
- "#<#{self.class}:#{'0x%x' % (__id__ << 1)} {:data=>#{_data.inspect}, :kept=>#{kept_data.inspect}} >"
115
- end
116
-
117
- # Set @keep to true, is use when triggering a redirect, and the content of _data is not empty.
118
- # @return [TrueClass, NilClass]
119
- #
120
- # @since 1.3.0
121
- # @api private
122
- #
123
- # @see Hanami::Action::Flash#empty?
124
- def keep!
125
- return if empty?
126
- @keep = true
66
+ # @api public
67
+ def []=(key, value)
68
+ @next[key] = value
127
69
  end
128
70
 
129
- private
130
-
131
- # The flash registry that holds the data for the current requests
71
+ # Calls the given block once for each element in the current hash
132
72
  #
133
- # @return [Hash] the flash
73
+ # @param block [Proc]
134
74
  #
135
- # @since 0.3.0
136
- # @api private
137
- def _data
138
- @session[SESSION_KEY] || {}
75
+ # @since 1.2.0
76
+ # @api public
77
+ def each(&block)
78
+ @flash.each(&block)
139
79
  end
140
80
 
141
- # Remove the flash entirely from the session if empty.
81
+ # Returns a new array with the results of running block once for every
82
+ # element in the current hash
142
83
  #
143
- # @return [void]
144
- #
145
- # @since 0.3.0
146
- # @api private
84
+ # @param block [Proc]
85
+ # @return [Array]
147
86
  #
148
- # @see Hanami::Action::Flash#empty?
149
- def remove
150
- if empty?
151
- @session.delete(SESSION_KEY)
152
- @session.delete(KEPT_KEY)
153
- end
87
+ # @since 1.2.0
88
+ # @api public
89
+ def map(&block)
90
+ @flash.map(&block)
154
91
  end
155
92
 
156
- # Returns the values from current session and kept.
93
+ # Returns `true` if the current hash contains no elements.
157
94
  #
158
- # @return [Hash]
95
+ # @return [Boolean]
159
96
  #
160
97
  # @since 0.3.0
161
- # @api private
162
- def _values
163
- _data.merge(kept_data)
98
+ # @api public
99
+ def empty?
100
+ @flash.empty?
164
101
  end
165
102
 
166
- # Get the kept request data
103
+ # Returns `true` if the given key is present in the current hash.
167
104
  #
168
- # @return [Array]
105
+ # @return [Boolean]
169
106
  #
170
- # @since 1.3.0
171
- # @api private
172
- def kept
173
- @session[KEPT_KEY] || []
107
+ # @since 2.0.0
108
+ # @api public
109
+ def key?(key)
110
+ @flash.key?(key)
174
111
  end
175
112
 
176
- # Merge current data into KEPT_KEY hash
113
+ # Removes entries from the next hash
177
114
  #
178
- # @return [Hash] the current value of KEPT_KEY
115
+ # @overload discard(key)
116
+ # Removes the given key from the next hash
179
117
  #
180
- # @since 1.3.0
181
- # @api private
182
- def keep_data
183
- new_kept_data = kept << Hanami::Utils::Json.generate({ count: 0, data: _data })
184
-
185
- update_kept(new_kept_data)
186
- end
187
-
188
- # Removes from kept data those who have lived for more than two requests
118
+ # @param key [Object] key to discard
189
119
  #
190
- # @return [Hash] the current value of KEPT_KEY
120
+ # @overload discard
121
+ # Clears the next hash
191
122
  #
192
- # @since 1.3.0
193
- # @api private
194
- def expire_kept
195
- new_kept_data = kept.reject do |kept_data|
196
- parsed = Hanami::Utils::Json.parse(kept_data)
197
- parsed['count'] >= 2 if is_hash?(parsed) && parsed['count'].is_a?(Integer)
123
+ # @since 2.0.0
124
+ # @api public
125
+ def discard(key = (no_arg = true))
126
+ if no_arg
127
+ @next.clear
128
+ else
129
+ @next.delete(key)
198
130
  end
199
-
200
- update_kept(new_kept_data)
201
131
  end
202
132
 
203
- # Update the count of request for each kept data
133
+ # Copies entries from the current hash to the next hash
204
134
  #
205
- # @return [Hash] the current value of KEPT_KEY
135
+ # @overload keep(key)
136
+ # Copies the entry for the given key from the current hash to the next
137
+ # hash
206
138
  #
207
- # @since 1.3.0
208
- # @api private
209
- def update_kept_request_count
210
- new_kept_data = kept.map do |kept_data|
211
- parsed = Hanami::Utils::Json.parse(kept_data)
212
- parsed['count'] += 1 if is_hash?(parsed) && parsed['count'].is_a?(Integer)
213
- Hanami::Utils::Json.generate(parsed)
214
- end
215
-
216
- update_kept(new_kept_data)
217
- end
218
-
219
- # Search in the kept data for a match on the key
139
+ # @param key [Object] key to copy
220
140
  #
221
- # @param key [#to_s] the key
222
- # @return [Object, NilClass]
141
+ # @overload keep
142
+ # Copies all entries from the current hash to the next hash
223
143
  #
224
- # @since 1.3.0
225
- # @api private
226
- def search_in_kept_data(key)
227
- string_key = key.to_s
228
-
229
- data = kept.find do |kept_data|
230
- parsed = Hanami::Utils::Json.parse(kept_data)
231
- parsed['data'].fetch(string_key, nil) if is_hash?(parsed['data'])
144
+ # @since 2.0.0
145
+ # @api public
146
+ def keep(key = (no_arg = true))
147
+ if no_arg
148
+ @next.merge!(@flash)
149
+ else
150
+ self[key] = self[key]
232
151
  end
233
-
234
- Hanami::Utils::Json.parse(data)['data'][string_key] if data
235
- end
236
-
237
- # Set the given new_kept_data to KEPT_KEY
238
- #
239
- # @param new_kept_data
240
- # @return [Hash] the current value of KEPT_KEY
241
- #
242
- # @since 1.3.0
243
- # @api private
244
- def update_kept(new_kept_data)
245
- @session[KEPT_KEY] = new_kept_data
246
- end
247
-
248
- # Values from kept
249
- #
250
- # @return [Hash]
251
- #
252
- # @since 1.3.0
253
- # @api private
254
- def kept_data
255
- kept.each_with_object({}) { |kept_data, result| result.merge!(Hanami::Utils::Json.parse(kept_data)['data']) }
256
152
  end
257
153
 
258
- # Check if data is a hash
259
- #
260
- # @param data
261
- # @return [TrueClass, FalseClass]
154
+ # Replaces the current hash with the next hash and clears the next hash
262
155
  #
263
- # @since 1.3.0
264
- # @api private
265
- def is_hash?(data)
266
- data && data.is_a?(::Hash)
156
+ # @since 2.0.0
157
+ # @api public
158
+ def sweep
159
+ @flash = @next.dup
160
+ @next.clear
161
+ self
267
162
  end
268
163
  end
269
164
  end