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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/LICENSE.md +1 -1
- data/README.md +5 -5
- data/hanami-controller.gemspec +4 -3
- data/lib/hanami/action.rb +14 -573
- data/lib/hanami/action/application_action.rb +111 -0
- data/lib/hanami/action/application_configuration.rb +92 -0
- data/lib/hanami/action/application_configuration/cookies.rb +29 -0
- data/lib/hanami/action/application_configuration/sessions.rb +46 -0
- data/lib/hanami/action/cache/conditional_get.rb +4 -1
- data/lib/hanami/action/configuration.rb +430 -0
- data/lib/hanami/action/csrf_protection.rb +214 -0
- data/lib/hanami/action/flash.rb +101 -206
- data/lib/hanami/action/mime.rb +8 -1
- data/lib/hanami/action/params.rb +1 -1
- data/lib/hanami/action/response.rb +43 -24
- data/lib/hanami/action/session.rb +6 -14
- data/lib/hanami/action/standalone_action.rb +581 -0
- data/lib/hanami/action/validatable.rb +1 -1
- data/lib/hanami/action/view_name_inferrer.rb +46 -0
- data/lib/hanami/controller.rb +0 -17
- data/lib/hanami/controller/version.rb +1 -1
- data/lib/hanami/http/status.rb +2 -2
- metadata +33 -13
- data/lib/hanami-controller.rb +0 -1
- data/lib/hanami/controller/configuration.rb +0 -308
@@ -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
|
data/lib/hanami/action/flash.rb
CHANGED
@@ -1,269 +1,164 @@
|
|
1
|
-
|
1
|
+
# frozen_string_literal: true
|
2
2
|
|
3
3
|
module Hanami
|
4
4
|
class Action
|
5
|
-
#
|
6
|
-
#
|
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
|
19
|
+
# @api public
|
10
20
|
class Flash
|
11
|
-
#
|
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
|
-
# @
|
23
|
+
# @see #[]=
|
28
24
|
#
|
29
|
-
# @
|
30
|
-
# @
|
31
|
-
|
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
|
-
#
|
29
|
+
# Initializes a new flash instance
|
51
30
|
#
|
52
|
-
# @
|
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
|
56
|
-
def
|
57
|
-
|
34
|
+
# @api public
|
35
|
+
def initialize(hash = {})
|
36
|
+
@flash = hash || {}
|
37
|
+
@next = {}
|
58
38
|
end
|
59
39
|
|
60
|
-
#
|
61
|
-
#
|
62
|
-
# @param blk [Proc]
|
40
|
+
# @return [Hash] The flash hash for the current request
|
63
41
|
#
|
64
|
-
# @since
|
65
|
-
|
66
|
-
|
42
|
+
# @since 2.0.0
|
43
|
+
# @api public
|
44
|
+
def now
|
45
|
+
@flash
|
67
46
|
end
|
68
47
|
|
69
|
-
#
|
48
|
+
# Returns the value for the given key in the current hash
|
70
49
|
#
|
71
|
-
# @param
|
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 [
|
52
|
+
# @return [Object, nil] the value
|
83
53
|
#
|
84
54
|
# @since 0.3.0
|
85
|
-
# @api
|
86
|
-
def
|
87
|
-
|
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
|
-
#
|
100
|
-
# previous request.
|
60
|
+
# Updates the next hash with the given key and value
|
101
61
|
#
|
102
|
-
# @
|
62
|
+
# @param key [Object] the key
|
63
|
+
# @param value [Object] the value
|
103
64
|
#
|
104
65
|
# @since 0.3.0
|
105
|
-
# @api
|
106
|
-
def
|
107
|
-
|
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
|
-
|
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
|
-
# @
|
73
|
+
# @param block [Proc]
|
134
74
|
#
|
135
|
-
# @since
|
136
|
-
# @api
|
137
|
-
def
|
138
|
-
@
|
75
|
+
# @since 1.2.0
|
76
|
+
# @api public
|
77
|
+
def each(&block)
|
78
|
+
@flash.each(&block)
|
139
79
|
end
|
140
80
|
|
141
|
-
#
|
81
|
+
# Returns a new array with the results of running block once for every
|
82
|
+
# element in the current hash
|
142
83
|
#
|
143
|
-
# @
|
144
|
-
#
|
145
|
-
# @since 0.3.0
|
146
|
-
# @api private
|
84
|
+
# @param block [Proc]
|
85
|
+
# @return [Array]
|
147
86
|
#
|
148
|
-
# @
|
149
|
-
|
150
|
-
|
151
|
-
|
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
|
93
|
+
# Returns `true` if the current hash contains no elements.
|
157
94
|
#
|
158
|
-
# @return [
|
95
|
+
# @return [Boolean]
|
159
96
|
#
|
160
97
|
# @since 0.3.0
|
161
|
-
# @api
|
162
|
-
def
|
163
|
-
|
98
|
+
# @api public
|
99
|
+
def empty?
|
100
|
+
@flash.empty?
|
164
101
|
end
|
165
102
|
|
166
|
-
#
|
103
|
+
# Returns `true` if the given key is present in the current hash.
|
167
104
|
#
|
168
|
-
# @return [
|
105
|
+
# @return [Boolean]
|
169
106
|
#
|
170
|
-
# @since
|
171
|
-
# @api
|
172
|
-
def
|
173
|
-
@
|
107
|
+
# @since 2.0.0
|
108
|
+
# @api public
|
109
|
+
def key?(key)
|
110
|
+
@flash.key?(key)
|
174
111
|
end
|
175
112
|
|
176
|
-
#
|
113
|
+
# Removes entries from the next hash
|
177
114
|
#
|
178
|
-
# @
|
115
|
+
# @overload discard(key)
|
116
|
+
# Removes the given key from the next hash
|
179
117
|
#
|
180
|
-
#
|
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
|
-
# @
|
120
|
+
# @overload discard
|
121
|
+
# Clears the next hash
|
191
122
|
#
|
192
|
-
# @since
|
193
|
-
# @api
|
194
|
-
def
|
195
|
-
|
196
|
-
|
197
|
-
|
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
|
-
#
|
133
|
+
# Copies entries from the current hash to the next hash
|
204
134
|
#
|
205
|
-
# @
|
135
|
+
# @overload keep(key)
|
136
|
+
# Copies the entry for the given key from the current hash to the next
|
137
|
+
# hash
|
206
138
|
#
|
207
|
-
#
|
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
|
-
# @
|
222
|
-
#
|
141
|
+
# @overload keep
|
142
|
+
# Copies all entries from the current hash to the next hash
|
223
143
|
#
|
224
|
-
# @since
|
225
|
-
# @api
|
226
|
-
def
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
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
|
-
#
|
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
|
264
|
-
# @api
|
265
|
-
def
|
266
|
-
|
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
|