hanami-controller 2.0.0.alpha1 → 2.0.0.alpha2

Sign up to get free protection for your applications and to get access to all the features.
@@ -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