roda 3.8.0 → 3.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG +4 -0
- data/README.rdoc +17 -5
- data/doc/release_notes/3.9.0.txt +67 -0
- data/lib/roda/plugins/csrf.rb +4 -0
- data/lib/roda/plugins/route_csrf.rb +351 -0
- data/lib/roda/plugins/typecast_params.rb +2 -2
- data/lib/roda/version.rb +1 -1
- data/spec/plugin/csrf_spec.rb +4 -4
- data/spec/plugin/flash_spec.rb +8 -7
- data/spec/plugin/head_spec.rb +1 -1
- data/spec/plugin/route_csrf_spec.rb +277 -0
- data/spec/plugin/typecast_params_spec.rb +8 -8
- data/spec/session_spec.rb +4 -2
- data/spec/spec_helper.rb +19 -0
- metadata +7 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 39ce926351959abb8a72a7b1c9db596dff7e0e84c26c296db4d2b3c124486356
|
4
|
+
data.tar.gz: 904ab94da8c67bfd2d4acb5246fa0a3049c89778e2fbc9e1701f6f342f39329a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 590cc6b72c9fbb3fc01e389a6c12a5d09cd0db1cf56fe0d821b79e0e3aaf1d91ca7598d25fbdbdc50b25171f79501087599bd334ce079ad435ee51be31988588
|
7
|
+
data.tar.gz: 8bcde66bd4ab812a963ace04db8b026df5d5b5c9d79432de4b5780da80f9548f1dc92681e2fe0bdd1330d20f526cab4cea27b4bd0d54dddedbc8b5a9ec864c77
|
data/CHANGELOG
CHANGED
@@ -1,3 +1,7 @@
|
|
1
|
+
= 3.9.0 (2018-06-11)
|
2
|
+
|
3
|
+
* Add route_csrf plugin for CSRF protection, offering more control, better security, and request-specific tokens compared to rack_csrf (jeremyevans)
|
4
|
+
|
1
5
|
= 3.8.0 (2018-05-17)
|
2
6
|
|
3
7
|
* Accept convert_each! :keys option that is Proc or Method in typecast_params plugin (jeremyevans)
|
data/README.rdoc
CHANGED
@@ -788,12 +788,24 @@ This means you should not store any secret data in the session.
|
|
788
788
|
|
789
789
|
=== Cross Site Request Forgery (CSRF)
|
790
790
|
|
791
|
-
CSRF can be prevented by using the +
|
792
|
-
|
793
|
-
|
791
|
+
CSRF can be prevented by using the +route_csrf+ plugin that ships with Roda.
|
792
|
+
The +route_csrf+ plugin uses modern security practices to create CSRF tokens,
|
793
|
+
requires request-specific tokens by default, and offers control to the user
|
794
|
+
over where in the routing tree that CSRF tokens are checked. For example, if
|
795
|
+
you are using the +public+ plugin to serve static files and the +assets+
|
796
|
+
plugin to serve assets, you wouldn't need to check for CSRF tokens for either
|
797
|
+
of those, so you could put the CSRF check after those in the routing tree,
|
798
|
+
but before handling other requests:
|
799
|
+
|
800
|
+
route do |r|
|
801
|
+
r.public
|
802
|
+
r.assets
|
803
|
+
|
804
|
+
check_csrf! # Must call this to check for valid CSRF tokens
|
805
|
+
|
806
|
+
# ...
|
807
|
+
end
|
794
808
|
|
795
|
-
It's also possible to use the <tt>Rack::Csrf</tt> middleware directly;
|
796
|
-
you don't have to use the +csrf+ plugin.
|
797
809
|
|
798
810
|
=== Cross Site Scripting (XSS)
|
799
811
|
|
@@ -0,0 +1,67 @@
|
|
1
|
+
= New Features
|
2
|
+
|
3
|
+
* A route_csrf plugin has been added. This plugin allows for more
|
4
|
+
control over CSRF protection, since the user can choose where in
|
5
|
+
the routing tree to enforce the protection. Additionally, the
|
6
|
+
route_csrf plugin offers better security than the CSRF protection
|
7
|
+
used by the csrf plugin (which uses the rack_csrf library).
|
8
|
+
|
9
|
+
The route_csrf plugin defaults to allowing only CSRF tokens
|
10
|
+
specific to a given request method and request path, and not
|
11
|
+
allowing generic CSRF tokens (though it does offer optional support
|
12
|
+
for such tokens). Both request-specific and generic CSRF tokens
|
13
|
+
are designed to never leak the CSRF secret key, making it more
|
14
|
+
difficult to forge valid CSRF tokens. Additionally, the plugin
|
15
|
+
offers optional support for accepting rack_csrf tokens, which
|
16
|
+
should only be enabled during a short transition period.
|
17
|
+
|
18
|
+
Some differences between the route_csrf plugin and the older
|
19
|
+
csrf plugin:
|
20
|
+
|
21
|
+
* route_csrf supports and by default only allows CSRF tokens
|
22
|
+
specific to request method and request path, as mentioned
|
23
|
+
above. You can use the require_request_specific_tokens: false
|
24
|
+
option to allow generic CSRF tokens.
|
25
|
+
|
26
|
+
* route_csrf does not check the HTTP header by default, it
|
27
|
+
only checks the header if the :check_header option is set.
|
28
|
+
The :check_header option can be set to true to check both
|
29
|
+
the parameter and the header, or set to :only to only check
|
30
|
+
the header.
|
31
|
+
|
32
|
+
* route_csrf raises by default for invalid CSRF tokens. rack_csrf
|
33
|
+
returns an empty 403 response in that case. You can use the
|
34
|
+
error_handler plugin to handle the
|
35
|
+
Roda::RodaPlugins::RouteCsrf::InvalidToken exceptions, or you
|
36
|
+
can use the csrf_failure: :empty_403 option if you would like
|
37
|
+
the csrf plugin default behavior. The plugin also accepts a
|
38
|
+
block for configurable failure behavior.
|
39
|
+
|
40
|
+
* route_csrf does not use a middleware, as it is designed to give
|
41
|
+
more control. In order to enforce the CSRF protection, you need
|
42
|
+
to call check_csrf! in your routing tree at the appropriate
|
43
|
+
place. If you are not sure where to add it, add it to the top
|
44
|
+
of the routing tree, after the public or assets routes if you
|
45
|
+
are using those plugins:
|
46
|
+
|
47
|
+
route do
|
48
|
+
r.public
|
49
|
+
r.assets
|
50
|
+
check_csrf!
|
51
|
+
|
52
|
+
# ...
|
53
|
+
end
|
54
|
+
|
55
|
+
The check_csrf! method accepts an options hash, which can be used
|
56
|
+
to override the plugin options on a per-call basis.
|
57
|
+
|
58
|
+
* The csrf_token/csrf_tag methods take an optional path and method
|
59
|
+
arguments. If a path is given, the method defaults to POST, and
|
60
|
+
the resulting CSRF token can only be used to submit forms for the
|
61
|
+
path and method. If a path is not given, the resulting CSRF token
|
62
|
+
will be generic, but it will only work if the plugin has been
|
63
|
+
configured to allow generic CSRF tokens.
|
64
|
+
|
65
|
+
* A csrf_path method is available for easily taking a form action
|
66
|
+
string and returning an appropriate path to pass to the csrf_token
|
67
|
+
or csrf_tag methods.
|
data/lib/roda/plugins/csrf.rb
CHANGED
@@ -4,6 +4,10 @@ require 'rack/csrf'
|
|
4
4
|
|
5
5
|
class Roda
|
6
6
|
module RodaPlugins
|
7
|
+
# This plugin is no longer recommended for use, it exists only for
|
8
|
+
# backwards compatibility. Consider using the route_csrf plugin
|
9
|
+
# instead, as that provides stronger CSRF protection.
|
10
|
+
#
|
7
11
|
# The csrf plugin adds CSRF protection using rack_csrf, along with
|
8
12
|
# some csrf helper methods to use in your views. To use it, load
|
9
13
|
# the plugin, with the options hash passed to Rack::Csrf:
|
@@ -0,0 +1,351 @@
|
|
1
|
+
# frozen-string-literal: true
|
2
|
+
|
3
|
+
require 'base64'
|
4
|
+
require 'openssl'
|
5
|
+
require 'securerandom'
|
6
|
+
require 'uri'
|
7
|
+
|
8
|
+
class Roda
|
9
|
+
module RodaPlugins
|
10
|
+
# The route_csrf plugin is the recommended plugin to use to support
|
11
|
+
# CSRF protection in Roda applications. This plugin allows you set
|
12
|
+
# where in the routing tree to enforce CSRF protection. Additionally,
|
13
|
+
# the route_csrf plugin uses modern security practices.
|
14
|
+
#
|
15
|
+
# By default, the plugin requires tokens be specific to the request
|
16
|
+
# method and request path, so a CSRF token generated for one form will
|
17
|
+
# not be usable to submit a different form.
|
18
|
+
#
|
19
|
+
# This plugin also takes care to not expose the underlying CSRF key
|
20
|
+
# (except in the session), so that it is not possible for an attacker
|
21
|
+
# to generate valid CSRF tokens specific to an arbitrary request method
|
22
|
+
# and request path even if they have access to a token that is not
|
23
|
+
# specific to request method and request path. To get this security
|
24
|
+
# benefit, you must ensure an attacker does not have access to the
|
25
|
+
# session. Rack::Session::Cookie uses signed sessions, not encrypted
|
26
|
+
# sessions, so if the attacker has the ability to read cookie data
|
27
|
+
# and you are using Rack::Session::Cookie, it will still be possible
|
28
|
+
# for an attacker to generate valid CSRF tokens specific to arbitrary
|
29
|
+
# request method and request path.
|
30
|
+
#
|
31
|
+
# # Usage
|
32
|
+
#
|
33
|
+
# It is recommended to use the plugin defaults, loading the
|
34
|
+
# plugin with no options:
|
35
|
+
#
|
36
|
+
# plugin :route_csrf
|
37
|
+
#
|
38
|
+
# This plugin supports the following options:
|
39
|
+
#
|
40
|
+
# :field :: Form input parameter name for CSRF token (default: '_csrf')
|
41
|
+
# :header :: HTTP header name for CSRF token (default: 'X-CSRF-Token')
|
42
|
+
# :key :: Session key for CSRF secret (default: '_roda_csrf_secret')
|
43
|
+
# :require_request_specific_tokens :: Whether request-specific tokens are required (default: true).
|
44
|
+
# A false value will allow tokens that are not request-specific
|
45
|
+
# to also work. You should only set this to false if it is
|
46
|
+
# impossible to use request-specific tokens. If you must
|
47
|
+
# use non-request-specific tokens in certain cases, it is best
|
48
|
+
# to leave this option true by default, and override it on a
|
49
|
+
# per call basis in those specific cases.
|
50
|
+
# :csrf_failure :: The action to taken if a request fails the CSRF check (default: :raise). Options:
|
51
|
+
# :raise :: raise a Roda::RodaPlugins::RouteCsrf::InvalidToken exception
|
52
|
+
# :empty_403 :: return a blank 403 page (rack_csrf's default behavior)
|
53
|
+
# :clear_session :: Clear the current session
|
54
|
+
# Proc :: Treated as a routing block, called with request object
|
55
|
+
# :check_header :: Whether the HTTP header should be checked for the token value (default: false).
|
56
|
+
# If true, checks the HTTP header after checking for the form input parameter.
|
57
|
+
# If :only, only checks the HTTP header and doesn't check the form input parameter.
|
58
|
+
# :check_request_methods :: Which request methods require CSRF protection
|
59
|
+
# (default: <tt>['POST', 'DELETE', 'PATCH', 'PUT']</tt>)
|
60
|
+
# :upgrade_from_rack_csrf_key :: If provided, the session key that should be checked for the
|
61
|
+
# rack_csrf raw token. If the session key is present, the value
|
62
|
+
# will be checked against the submitted token, and if it matches,
|
63
|
+
# the CSRF check will be passed. Should only be set temporarily
|
64
|
+
# if upgrading from using rack_csrf to the route_csrf plugin, and
|
65
|
+
# should be removed as soon as you are OK with CSRF forms generated
|
66
|
+
# before the upgrade not longer being usable. The default rack_csrf
|
67
|
+
# key is <tt>'csrf.token'</tt>.
|
68
|
+
#
|
69
|
+
# The plugin also supports a block, in which case the block will be used
|
70
|
+
# as the value of the :csrf_failure option.
|
71
|
+
#
|
72
|
+
# # Methods
|
73
|
+
#
|
74
|
+
# This adds the following instance methods:
|
75
|
+
#
|
76
|
+
# check_csrf!(opts={}) :: Used for checking if the submitted CSRF token is valid.
|
77
|
+
# If a block is provided, it is treated as a routing block if the
|
78
|
+
# CSRF token is not valid. Otherwise, by default, raises a
|
79
|
+
# Roda::RodaPlugins::RouteCsrf::InvalidToken exception if a CSRF
|
80
|
+
# token is necessary for the request and there is no token provided
|
81
|
+
# or the provided token is not valid. Options can be provided to
|
82
|
+
# override any of the plugin options for this specific call.
|
83
|
+
# The :token option can be used to specify the provided CSRF token
|
84
|
+
# (instead of looking for the token in the submitted parameters).
|
85
|
+
# csrf_field :: The field name to use for the hidden tag containing the CSRF token.
|
86
|
+
# csrf_path(action) :: This takes an argument that would be the value of the HTML form's
|
87
|
+
# action attribute, and returns a path you can pass to csrf_token
|
88
|
+
# that should be valid for the form submission. The argument should
|
89
|
+
# either be nil or a string representing a relative path, absolute
|
90
|
+
# path, or full URL.
|
91
|
+
# csrf_tag(path=nil, method='POST') :: An HTML hidden input tag string containing the CSRF token, suitable
|
92
|
+
# for placing in an HTML form. Takes the same arguments as csrf_token.
|
93
|
+
# csrf_token(path=nil, method='POST') :: The value of the csrf token, in case it needs to be accessed
|
94
|
+
# directly. It is recommended to call this method with a
|
95
|
+
# path, which will create a request-specific token. Calling
|
96
|
+
# this method without an argument will create a token that is
|
97
|
+
# not specific to the request, but such a token will only
|
98
|
+
# work if you set the :require_request_specific_tokens option
|
99
|
+
# to false, which is a bad idea from a security standpoint.
|
100
|
+
# use_request_specific_csrf_tokens? :: Whether the plugin is configured to only support
|
101
|
+
# request-specific tokens, true by default.
|
102
|
+
# valid_csrf?(opts={}) :: Returns whether the submitted CSRF token is valid (also true if
|
103
|
+
# the request does not require a CSRF token). Takes same option hash
|
104
|
+
# as check_csrf!.
|
105
|
+
#
|
106
|
+
# This plugin also adds the following instance methods for compatibility with the
|
107
|
+
# older csrf plugin, but it is not recommended to use these methods in new code:
|
108
|
+
#
|
109
|
+
# csrf_header :: The header name to use for submitting the CSRF token via an HTTP header
|
110
|
+
# (useful for javascript). Note that this plugin will not look in
|
111
|
+
# the HTTP header by default, it will only do so if the :check_header
|
112
|
+
# option is used.
|
113
|
+
# csrf_metatag :: An HTML meta tag string containing the CSRF token, suitable
|
114
|
+
# for placing in the page header. It is not recommended to use
|
115
|
+
# this method, as the token generated is not request-specific and
|
116
|
+
# will not work unless you set the :require_request_specific_tokens option to
|
117
|
+
# false, which is a bad idea from a security standpoint.
|
118
|
+
#
|
119
|
+
# # Token Cryptography
|
120
|
+
#
|
121
|
+
# route_csrf uses HMAC-SHA-256 to generate all CSRF tokens. It generates a random 32-byte secret,
|
122
|
+
# which is stored base64 encoded in the session. For each CSRF token, it generates 31 bytes
|
123
|
+
# of random data.
|
124
|
+
#
|
125
|
+
# For request-specific CSRF tokens, this pseudocode generates the HMAC:
|
126
|
+
#
|
127
|
+
# hmac = HMAC(secret, method + path + random_data)
|
128
|
+
#
|
129
|
+
# For CSRF tokens not specific to a request, this pseudocode generates the HMAC:
|
130
|
+
#
|
131
|
+
# hmac = HMAC(secret, random_data)
|
132
|
+
#
|
133
|
+
# This pseudocode generates the final CSRF token in both cases:
|
134
|
+
#
|
135
|
+
# token = Base64Encode(random_data + hmac)
|
136
|
+
#
|
137
|
+
# Using this construction for generating CSRF tokens means that generating any
|
138
|
+
# valid CSRF token without knowledge of the secret is equivalent to a successful generic attack
|
139
|
+
# on HMAC-SHA-256.
|
140
|
+
#
|
141
|
+
# By using an HMAC for tokens not specific to a request, it is not possible to use a
|
142
|
+
# valid CSRF token that is not specific to a request to generate a valid request-specific
|
143
|
+
# CSRF token.
|
144
|
+
#
|
145
|
+
# By including random data in the HMAC for all tokens, different tokens are generated
|
146
|
+
# each time, mitigating compression ratio attacks such as BREACH.
|
147
|
+
module RouteCsrf
|
148
|
+
# Default CSRF option values
|
149
|
+
DEFAULTS = {
|
150
|
+
:field => '_csrf'.freeze,
|
151
|
+
:header => 'X-CSRF-Token'.freeze,
|
152
|
+
:key => '_roda_csrf_secret'.freeze,
|
153
|
+
:require_request_specific_tokens => true,
|
154
|
+
:csrf_failure => :raise,
|
155
|
+
:check_header => false,
|
156
|
+
:check_request_methods => %w'POST DELETE PATCH PUT'.freeze.each(&:freeze)
|
157
|
+
}.freeze
|
158
|
+
|
159
|
+
# Exception class raised when :csrf_failure option is :raise and
|
160
|
+
# a valid CSRF token was not provided.
|
161
|
+
class InvalidToken < RodaError; end
|
162
|
+
|
163
|
+
def self.configure(app, opts=OPTS, &block)
|
164
|
+
options = app.opts[:route_csrf] = (app.opts[:route_csrf] || DEFAULTS).merge(opts)
|
165
|
+
if block
|
166
|
+
if opts[:csrf_failure]
|
167
|
+
raise RodaError, "Cannot specify both route_csrf plugin block and :csrf_failure option"
|
168
|
+
end
|
169
|
+
options[:csrf_failure] = block
|
170
|
+
end
|
171
|
+
options[:env_header] = "HTTP_#{options[:header].to_s.gsub('-', '_').upcase}".freeze
|
172
|
+
options.freeze
|
173
|
+
end
|
174
|
+
|
175
|
+
module InstanceMethods
|
176
|
+
# Check that the submitted CSRF token is valid, if the request requires a CSRF token.
|
177
|
+
# If the CSRF token is valid or the request does not require a CSRF token, return nil.
|
178
|
+
# Otherwise, if a block is given, treat it as a routing block and yield to it, and
|
179
|
+
# if a block is not given, use the :csrf_failure option to determine how to handle it.
|
180
|
+
def check_csrf!(opts=OPTS, &block)
|
181
|
+
if msg = csrf_invalid_message(opts)
|
182
|
+
if block
|
183
|
+
@_request.on(&block)
|
184
|
+
end
|
185
|
+
|
186
|
+
case failure_action = opts.fetch(:csrf_failure, csrf_options[:csrf_failure])
|
187
|
+
when :raise
|
188
|
+
raise InvalidToken, msg
|
189
|
+
when :empty_403
|
190
|
+
throw :halt, [403, {'Content-Type'=>'text/html', 'Content-Length'=>'0'}, []]
|
191
|
+
when :clear_session
|
192
|
+
session.clear
|
193
|
+
when Proc
|
194
|
+
@_request.on{instance_exec(@_request, &failure_action)}
|
195
|
+
else
|
196
|
+
raise RodaError, "Unsupported :csrf_failure option: #{failure_action.inspect}"
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
# The name of the hidden input tag containing the CSRF token. Also used as the name
|
202
|
+
# for the meta tag.
|
203
|
+
def csrf_field
|
204
|
+
csrf_options[:field]
|
205
|
+
end
|
206
|
+
|
207
|
+
# The HTTP header name to use when submitting CSRF tokens in an HTTP header, if
|
208
|
+
# such support is enabled (it is not by default).
|
209
|
+
def csrf_header
|
210
|
+
csrf_options[:header]
|
211
|
+
end
|
212
|
+
|
213
|
+
# An HTML meta tag string containing a CSRF token that is not request-specific.
|
214
|
+
# It is not recommended to use this, as it doesn't support request-specific tokens.
|
215
|
+
def csrf_metatag
|
216
|
+
"<meta name=\"#{csrf_field}\" content=\"#{csrf_token}\" \/>"
|
217
|
+
end
|
218
|
+
|
219
|
+
# Given a form action, return the appropriate path to use for the CSRF token.
|
220
|
+
# This makes it easier to generate request-specific tokens without having to
|
221
|
+
# worry about the different types of form actions (relative paths, absolute
|
222
|
+
# paths, URLs, empty paths).
|
223
|
+
def csrf_path(action)
|
224
|
+
case action
|
225
|
+
when nil, '', /\A[#?]/
|
226
|
+
# use current path
|
227
|
+
request.path
|
228
|
+
when /\A(?:https?:\/)?\//
|
229
|
+
# Either full URI or absolute path, extract just the path
|
230
|
+
URI.parse(action).path
|
231
|
+
else
|
232
|
+
# relative path, join to current path
|
233
|
+
URI.join(request.url, action).path
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# An HTML hidden input tag string containing the CSRF token. See csrf_token for
|
238
|
+
# arguments.
|
239
|
+
def csrf_tag(*args)
|
240
|
+
"<input type=\"hidden\" name=\"#{csrf_field}\" value=\"#{csrf_token(*args)}\" \/>"
|
241
|
+
end
|
242
|
+
|
243
|
+
# The value of the csrf token. For a path specific token, provide a path
|
244
|
+
# argument. By default, it a path is provided, the POST request method will
|
245
|
+
# be assumed. To generate a token for a non-POST request method, pass the
|
246
|
+
# method as the second argument.
|
247
|
+
def csrf_token(path=nil, method=('POST' if path))
|
248
|
+
token = SecureRandom.random_bytes(31)
|
249
|
+
token << csrf_hmac(token, method, path)
|
250
|
+
Base64.strict_encode64(token)
|
251
|
+
end
|
252
|
+
|
253
|
+
# Whether request-specific CSRF tokens should be used by default.
|
254
|
+
def use_request_specific_csrf_tokens?
|
255
|
+
csrf_options[:require_request_specific_tokens]
|
256
|
+
end
|
257
|
+
|
258
|
+
# Whether the submitted CSRF token is valid for the request. True if the
|
259
|
+
# request does not require a CSRF token.
|
260
|
+
def valid_csrf?(opts=OPTS)
|
261
|
+
csrf_invalid_message(opts).nil?
|
262
|
+
end
|
263
|
+
|
264
|
+
private
|
265
|
+
|
266
|
+
# Returns error message string if the CSRF token is not valid.
|
267
|
+
# Returns nil if the CSRF token is valid.
|
268
|
+
def csrf_invalid_message(opts)
|
269
|
+
opts = opts.empty? ? csrf_options : csrf_options.merge(opts)
|
270
|
+
method = request.request_method
|
271
|
+
|
272
|
+
unless opts[:check_request_methods].include?(method)
|
273
|
+
return
|
274
|
+
end
|
275
|
+
|
276
|
+
unless encoded_token = opts[:token]
|
277
|
+
encoded_token = case opts[:check_header]
|
278
|
+
when :only
|
279
|
+
env[opts[:env_header]]
|
280
|
+
when true
|
281
|
+
return (csrf_invalid_message(opts.merge(:check_header=>false)) && csrf_invalid_message(opts.merge(:check_header=>:only)))
|
282
|
+
else
|
283
|
+
@_request.params[opts[:field]]
|
284
|
+
end
|
285
|
+
end
|
286
|
+
|
287
|
+
unless encoded_token.is_a?(String)
|
288
|
+
return "encoded token is not a string"
|
289
|
+
end
|
290
|
+
|
291
|
+
if (rack_csrf_key = opts[:upgrade_from_rack_csrf_key]) && (rack_csrf_value = session[rack_csrf_key]) && csrf_compare(rack_csrf_value, encoded_token)
|
292
|
+
return
|
293
|
+
end
|
294
|
+
|
295
|
+
# 31 byte random initialization vector
|
296
|
+
# 32 byte HMAC
|
297
|
+
# 63 bytes total
|
298
|
+
# 84 bytes when base64 encoded
|
299
|
+
unless encoded_token.bytesize == 84
|
300
|
+
return "encoded token length is not 84"
|
301
|
+
end
|
302
|
+
|
303
|
+
begin
|
304
|
+
submitted_hmac = Base64.strict_decode64(encoded_token)
|
305
|
+
rescue ArgumentError
|
306
|
+
return "encoded token is not valid base64"
|
307
|
+
end
|
308
|
+
|
309
|
+
random_data = submitted_hmac.slice!(0...31)
|
310
|
+
|
311
|
+
if csrf_compare(csrf_hmac(random_data, method, @_request.path), submitted_hmac)
|
312
|
+
return
|
313
|
+
end
|
314
|
+
|
315
|
+
if opts[:require_request_specific_tokens]
|
316
|
+
"decoded token is not valid for request method and path"
|
317
|
+
else
|
318
|
+
unless csrf_compare(csrf_hmac(random_data, '', ''), submitted_hmac)
|
319
|
+
"decoded token is not valid for either request method and path or for blank method and path"
|
320
|
+
end
|
321
|
+
end
|
322
|
+
end
|
323
|
+
|
324
|
+
# Helper for getting the plugin options.
|
325
|
+
def csrf_options
|
326
|
+
opts[:route_csrf]
|
327
|
+
end
|
328
|
+
|
329
|
+
# Perform a constant-time comparison of the two strings, returning true if they match and false otherwise.
|
330
|
+
def csrf_compare(s1, s2)
|
331
|
+
Rack::Utils.secure_compare(s1, s2)
|
332
|
+
end
|
333
|
+
|
334
|
+
# Return the HMAC-SHA-256 for the secret and the given arguments.
|
335
|
+
def csrf_hmac(random_data, method, path)
|
336
|
+
OpenSSL::HMAC.digest(OpenSSL::Digest::SHA256.new, csrf_secret, "#{method.to_s.upcase}#{path}#{random_data}")
|
337
|
+
end
|
338
|
+
|
339
|
+
# If a secret has not already been specified, generate a random 32-byte
|
340
|
+
# secret, stored base64 encoded in the session (to handle cases where
|
341
|
+
# JSON is used for session serialization).
|
342
|
+
def csrf_secret
|
343
|
+
key = session[csrf_options[:key]] ||= SecureRandom.base64(32)
|
344
|
+
Base64.strict_decode64(key)
|
345
|
+
end
|
346
|
+
end
|
347
|
+
end
|
348
|
+
|
349
|
+
register_plugin(:route_csrf, RouteCsrf)
|
350
|
+
end
|
351
|
+
end
|
data/lib/roda/version.rb
CHANGED
data/spec/plugin/csrf_spec.rb
CHANGED
@@ -6,6 +6,8 @@ rescue LoadError
|
|
6
6
|
warn "rack_csrf not installed, skipping csrf plugin test"
|
7
7
|
else
|
8
8
|
describe "csrf plugin" do
|
9
|
+
include CookieJar
|
10
|
+
|
9
11
|
it "adds csrf protection and csrf helper methods" do
|
10
12
|
app(:bare) do
|
11
13
|
use Rack::Session::Cookie, :secret=>'1'
|
@@ -33,7 +35,6 @@ describe "csrf plugin" do
|
|
33
35
|
status('REQUEST_METHOD'=>'POST', 'rack.input'=>io).must_equal 403
|
34
36
|
body('/foo', 'REQUEST_METHOD'=>'POST', 'rack.input'=>io).must_equal 'bar'
|
35
37
|
|
36
|
-
env = proc{|h| h['Set-Cookie'] ? {'HTTP_COOKIE'=>h['Set-Cookie'].sub("; path=/; HttpOnly", '')} : {}}
|
37
38
|
s, h, b = req
|
38
39
|
s.must_equal 200
|
39
40
|
field = h['FIELD']
|
@@ -41,7 +42,7 @@ describe "csrf plugin" do
|
|
41
42
|
h['TAG'].must_match(/\A<input type="hidden" name="#{field}" value="#{token}" \/>\z/)
|
42
43
|
h['METATAG'].must_match(/\A<meta name="#{field}" content="#{token}" \/>\z/)
|
43
44
|
b.must_equal ['g']
|
44
|
-
s, _, b = req('
|
45
|
+
s, _, b = req('REQUEST_METHOD'=>'POST', 'rack.input'=>io, "HTTP_#{h['HEADER']}"=>h['TOKEN'])
|
45
46
|
s.must_equal 200
|
46
47
|
b.must_equal ['p']
|
47
48
|
|
@@ -87,7 +88,6 @@ describe "csrf plugin" do
|
|
87
88
|
status('/foo', 'REQUEST_METHOD'=>'POST', 'rack.input'=>io).must_equal 403
|
88
89
|
body('/foo/bar', 'REQUEST_METHOD'=>'POST', 'rack.input'=>io).must_equal 'foobar'
|
89
90
|
|
90
|
-
env = proc{|h| h['Set-Cookie'] ? {'HTTP_COOKIE'=>h['Set-Cookie'].sub("; path=/; HttpOnly", '')} : {}}
|
91
91
|
s, h, b = req('/foo')
|
92
92
|
s.must_equal 200
|
93
93
|
field = h['FIELD']
|
@@ -95,7 +95,7 @@ describe "csrf plugin" do
|
|
95
95
|
h['TAG'].must_match(/\A<input type="hidden" name="#{field}" value="#{token}" \/>\z/)
|
96
96
|
h['METATAG'].must_match(/\A<meta name="#{field}" content="#{token}" \/>\z/)
|
97
97
|
b.must_equal ['g']
|
98
|
-
s, _, b = req('/foo',
|
98
|
+
s, _, b = req('/foo', 'REQUEST_METHOD'=>'POST', 'rack.input'=>io, "HTTP_#{h['HEADER']}"=>h['TOKEN'])
|
99
99
|
s.must_equal 200
|
100
100
|
b.must_equal ['p']
|
101
101
|
|
data/spec/plugin/flash_spec.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require_relative "../spec_helper"
|
2
2
|
|
3
3
|
describe "flash plugin" do
|
4
|
+
include CookieJar
|
5
|
+
|
4
6
|
it "flash.now[] sets flash for current page" do
|
5
7
|
app(:bare) do
|
6
8
|
use Rack::Session::Cookie, :secret => "1"
|
@@ -34,20 +36,19 @@ describe "flash plugin" do
|
|
34
36
|
end
|
35
37
|
end
|
36
38
|
|
37
|
-
env = proc{|h| h['Set-Cookie'] ? {'HTTP_COOKIE'=>h['Set-Cookie'].sub("; path=/; HttpOnly", '')} : {}}
|
38
39
|
_, h, b = req
|
39
40
|
b.join.must_equal ''
|
40
|
-
_, h, b = req
|
41
|
+
_, h, b = req
|
41
42
|
b.join.must_equal 'b'
|
42
|
-
_, h, b = req
|
43
|
+
_, h, b = req
|
43
44
|
b.join.must_equal 'bb'
|
44
|
-
_, h, b = req('/a'
|
45
|
+
_, h, b = req('/a')
|
45
46
|
b.join.must_equal 'cbbb'
|
46
|
-
_, h, b = req
|
47
|
+
_, h, b = req
|
47
48
|
b.join.must_equal ''
|
48
|
-
_, h, b = req
|
49
|
+
_, h, b = req
|
49
50
|
b.join.must_equal 'b'
|
50
|
-
_, h, b = req
|
51
|
+
_, h, b = req
|
51
52
|
b.join.must_equal 'bb'
|
52
53
|
end
|
53
54
|
end
|
data/spec/plugin/head_spec.rb
CHANGED
@@ -0,0 +1,277 @@
|
|
1
|
+
require_relative "../spec_helper"
|
2
|
+
|
3
|
+
describe "route_csrf plugin" do
|
4
|
+
include CookieJar
|
5
|
+
|
6
|
+
def route_csrf_app(opts={}, &block)
|
7
|
+
app(:bare) do
|
8
|
+
use Rack::Session::Cookie, :secret=>'1'
|
9
|
+
plugin(:route_csrf, opts, &opts[:block])
|
10
|
+
route do |r|
|
11
|
+
check_csrf! unless env['SKIP']
|
12
|
+
r.post('foo'){'f'}
|
13
|
+
r.post('bar'){'b'}
|
14
|
+
r.get "token", String do |s|
|
15
|
+
csrf_token("/#{s}")
|
16
|
+
end
|
17
|
+
instance_exec(r, &block) if block
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
it "allows all GET requests and allows POST requests only if they have a correct token for the path" do
|
23
|
+
route_csrf_app
|
24
|
+
token = body("/token/foo")
|
25
|
+
token.length.must_equal 84
|
26
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'f'
|
27
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
28
|
+
proc{body("/foo", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
29
|
+
|
30
|
+
token = body("/token/bar")
|
31
|
+
token.length.must_equal 84
|
32
|
+
body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'b'
|
33
|
+
proc{body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
34
|
+
proc{body("/bar", "REQUEST_METHOD"=>'DELETE', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
35
|
+
|
36
|
+
# Additional failure cases
|
37
|
+
|
38
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new)}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
39
|
+
|
40
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}a"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
41
|
+
|
42
|
+
t2 = token.dup
|
43
|
+
t2.setbyte(1, t2.getbyte(1) ^ 1)
|
44
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(t2)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
45
|
+
|
46
|
+
t2 = token.dup
|
47
|
+
t2.setbyte(61, t2.getbyte(61) ^ 1)
|
48
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(t2)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
49
|
+
|
50
|
+
t2 = token.dup
|
51
|
+
t2[1] = '|'
|
52
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(t2)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
53
|
+
end
|
54
|
+
|
55
|
+
it "supports :require_request_specific_tokens => false option to allow non-request-specific tokens" do
|
56
|
+
route_csrf_app(:require_request_specific_tokens=>false){csrf_token}
|
57
|
+
token = body("/token/foo")
|
58
|
+
token.length.must_equal 84
|
59
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'f'
|
60
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
61
|
+
|
62
|
+
token = body
|
63
|
+
token.length.must_equal 84
|
64
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'f'
|
65
|
+
body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'b'
|
66
|
+
end
|
67
|
+
|
68
|
+
it "allows tokens submitted in both parameter and HTTP header if :check_header option is true" do
|
69
|
+
route_csrf_app(:check_header=>true)
|
70
|
+
token = body("/token/foo")
|
71
|
+
token.length.must_equal 84
|
72
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'f'
|
73
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new, 'HTTP_X_CSRF_TOKEN'=>token).must_equal 'f'
|
74
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
75
|
+
proc{body("/foo", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new, 'HTTP_X_CSRF_TOKEN'=>token)}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
76
|
+
end
|
77
|
+
|
78
|
+
it "allows tokens submitted in only HTTP header if :check_header option is :only" do
|
79
|
+
route_csrf_app(:check_header=>:only)
|
80
|
+
token = body("/token/foo")
|
81
|
+
token.length.must_equal 84
|
82
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new, 'HTTP_X_CSRF_TOKEN'=>token).must_equal 'f'
|
83
|
+
proc{body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
84
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
85
|
+
proc{body("/foo", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new, 'HTTP_X_CSRF_TOKEN'=>token)}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
86
|
+
end
|
87
|
+
|
88
|
+
it "allows configuring CSRF failure action with :csrf_failure => :empty_403 option" do
|
89
|
+
route_csrf_app(:csrf_failure=>:empty_403)
|
90
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body("/token/foo"))}")).must_equal 'f'
|
91
|
+
req("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new).must_equal [403, {'Content-Type'=>'text/html', 'Content-Length'=>'0'}, []]
|
92
|
+
end
|
93
|
+
|
94
|
+
it "allows configuring CSRF failure action with :csrf_failure => :empty_403 option" do
|
95
|
+
route_csrf_app(:csrf_failure=>:clear_session){session.inspect}
|
96
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body("/token/foo"))}")).must_equal 'f'
|
97
|
+
body("/b", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/token/a'))}")).must_equal '{}'
|
98
|
+
end
|
99
|
+
|
100
|
+
it "allows configuring CSRF failure action with :csrf_failure => proc option" do
|
101
|
+
route_csrf_app(:csrf_failure=>proc{|r| r.path + '2'})
|
102
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body("/token/foo"))}")).must_equal 'f'
|
103
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new).must_equal '/foo2'
|
104
|
+
end
|
105
|
+
|
106
|
+
it "allows configuring CSRF failure action via a plugin block" do
|
107
|
+
route_csrf_app(:block=>proc{|r| r.path + '2'})
|
108
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body("/token/foo"))}")).must_equal 'f'
|
109
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new).must_equal '/foo2'
|
110
|
+
end
|
111
|
+
|
112
|
+
it "raises Error if configuring plugin with invalid :csrf_failure option" do
|
113
|
+
route_csrf_app(:csrf_failure=>:foo)
|
114
|
+
proc{body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new)}.must_raise Roda::RodaError
|
115
|
+
end
|
116
|
+
|
117
|
+
it "raises Error if configuring plugin with block and :csrf_failure option" do
|
118
|
+
proc{route_csrf_app(:block=>proc{|r| r.path + '2'}, :csrf_failure=>:raise)}.must_raise Roda::RodaError
|
119
|
+
end
|
120
|
+
|
121
|
+
it "supports valid_csrf? method" do
|
122
|
+
route_csrf_app{valid_csrf?.to_s}
|
123
|
+
body("/a", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body("/token/a"))}")).must_equal 'true'
|
124
|
+
body("/a", "REQUEST_METHOD"=>'POST', 'SKIP'=>true, 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body("/token/b"))}")).must_equal 'false'
|
125
|
+
end
|
126
|
+
|
127
|
+
it "supports valid_csrf? method" do
|
128
|
+
route_csrf_app do
|
129
|
+
check_csrf!{'nope'}
|
130
|
+
'yep'
|
131
|
+
end
|
132
|
+
body("/a", "REQUEST_METHOD"=>'POST', 'SKIP'=>true, 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body("/token/a"))}")).must_equal 'yep'
|
133
|
+
body("/a", "REQUEST_METHOD"=>'POST', 'SKIP'=>true, 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body("/token/b"))}")).must_equal 'nope'
|
134
|
+
end
|
135
|
+
|
136
|
+
it "supports use_request_specific_csrf_tokens? method" do
|
137
|
+
route_csrf_app{use_request_specific_csrf_tokens?.to_s}
|
138
|
+
body.must_equal 'true'
|
139
|
+
route_csrf_app(:require_request_specific_tokens=>false){use_request_specific_csrf_tokens?.to_s}
|
140
|
+
body.must_equal 'false'
|
141
|
+
end
|
142
|
+
|
143
|
+
it "supports csrf_field method" do
|
144
|
+
route_csrf_app{csrf_field}
|
145
|
+
body.must_equal '_csrf'
|
146
|
+
route_csrf_app(:field=>'foo'){csrf_field}
|
147
|
+
body.must_equal 'foo'
|
148
|
+
end
|
149
|
+
|
150
|
+
it "supports csrf_header method" do
|
151
|
+
route_csrf_app{csrf_header}
|
152
|
+
body.must_equal 'X-CSRF-Token'
|
153
|
+
route_csrf_app(:header=>'Foo'){csrf_header}
|
154
|
+
body.must_equal 'Foo'
|
155
|
+
end
|
156
|
+
|
157
|
+
it "supports csrf_metatag method" do
|
158
|
+
route_csrf_app(:require_request_specific_tokens=>false){csrf_metatag}
|
159
|
+
body =~ /\A<meta name="_csrf" content="([+\/0-9A-Za-z]{84})" \/>\z/
|
160
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape($1)}")).must_equal 'f'
|
161
|
+
|
162
|
+
route_csrf_app(:require_request_specific_tokens=>false, :field=>'foo'){csrf_metatag}
|
163
|
+
body =~ /\A<meta name="foo" content="([+\/0-9A-Za-z]{84})" \/>\z/
|
164
|
+
body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("foo=#{Rack::Utils.escape($1)}")).must_equal 'b'
|
165
|
+
end
|
166
|
+
|
167
|
+
it "supports csrf_tag method" do
|
168
|
+
route_csrf_app(:require_request_specific_tokens=>false){csrf_tag}
|
169
|
+
body =~ /\A<input type="hidden" name="_csrf" value="([+\/0-9A-Za-z]{84})" \/>\z/
|
170
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape($1)}")).must_equal 'f'
|
171
|
+
|
172
|
+
route_csrf_app(:require_request_specific_tokens=>false, :field=>'foo'){csrf_tag}
|
173
|
+
body =~ /\A<input type="hidden" name="foo" value="([+\/0-9A-Za-z]{84})" \/>\z/
|
174
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("foo=#{Rack::Utils.escape($1)}")).must_equal 'f'
|
175
|
+
|
176
|
+
route_csrf_app{csrf_tag('/foo')}
|
177
|
+
body =~ /\A<input type="hidden" name="_csrf" value="([+\/0-9A-Za-z]{84})" \/>\z/
|
178
|
+
token = $1
|
179
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'f'
|
180
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
181
|
+
proc{body("/foo", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new, 'QUERY_STRING'=>"_csrf=#{Rack::Utils.escape(token)}")}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
182
|
+
|
183
|
+
route_csrf_app do |r|
|
184
|
+
r.is 'foo', :method=>'PUT' do
|
185
|
+
'f2'
|
186
|
+
end
|
187
|
+
csrf_tag('/foo', 'PUT')
|
188
|
+
end
|
189
|
+
body =~ /\A<input type="hidden" name="_csrf" value="([+\/0-9A-Za-z]{84})" \/>\z/
|
190
|
+
token = $1
|
191
|
+
body("/foo", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new, 'QUERY_STRING'=>"_csrf=#{Rack::Utils.escape(token)}").must_equal 'f2'
|
192
|
+
proc{body("/bar", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new, 'QUERY_STRING'=>"_csrf=#{Rack::Utils.escape(token)}")}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
193
|
+
proc{body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
194
|
+
end
|
195
|
+
|
196
|
+
it "supports csrf_tag method" do
|
197
|
+
route_csrf_app(:require_request_specific_tokens=>false){csrf_token}
|
198
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body)}")).must_equal 'f'
|
199
|
+
|
200
|
+
route_csrf_app(:require_request_specific_tokens=>false, :field=>'foo'){csrf_token}
|
201
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("foo=#{Rack::Utils.escape(body)}")).must_equal 'f'
|
202
|
+
|
203
|
+
route_csrf_app{csrf_token('/foo')}
|
204
|
+
token = body
|
205
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'f'
|
206
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
207
|
+
proc{body("/foo", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new, 'QUERY_STRING'=>"_csrf=#{Rack::Utils.escape(token)}")}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
208
|
+
|
209
|
+
route_csrf_app do |r|
|
210
|
+
r.is 'foo', :method=>'PUT' do
|
211
|
+
'f2'
|
212
|
+
end
|
213
|
+
csrf_token('/foo', 'PUT')
|
214
|
+
end
|
215
|
+
token = body
|
216
|
+
body("/foo", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new, 'QUERY_STRING'=>"_csrf=#{Rack::Utils.escape(token)}").must_equal 'f2'
|
217
|
+
proc{body("/bar", "REQUEST_METHOD"=>'PUT', 'rack.input'=>StringIO.new, 'QUERY_STRING'=>"_csrf=#{Rack::Utils.escape(token)}")}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
218
|
+
proc{body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
219
|
+
end
|
220
|
+
|
221
|
+
it "supports csrf_path method" do
|
222
|
+
route_csrf_app do |r|
|
223
|
+
r.post{r.path + '2'}
|
224
|
+
csrf_token(csrf_path(env['CP']))
|
225
|
+
end
|
226
|
+
|
227
|
+
body("REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>nil))}")).must_equal '/2'
|
228
|
+
body("REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>''))}")).must_equal '/2'
|
229
|
+
body("REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>'#foo'))}")).must_equal '/2'
|
230
|
+
body("REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>'?foo'))}")).must_equal '/2'
|
231
|
+
|
232
|
+
body('/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/a', 'CP'=>nil))}")).must_equal '/a2'
|
233
|
+
body('/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/a', 'CP'=>''))}")).must_equal '/a2'
|
234
|
+
body('/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/a', 'CP'=>'?foo'))}")).must_equal '/a2'
|
235
|
+
body('/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/a', 'CP'=>'#foo'))}")).must_equal '/a2'
|
236
|
+
|
237
|
+
body("REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>'http://foo/'))}")).must_equal '/2'
|
238
|
+
body('/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>'https://foo/a'))}")).must_equal '/a2'
|
239
|
+
body('/a/b', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>'http://foo/a/b'))}")).must_equal '/a/b2'
|
240
|
+
|
241
|
+
body("REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>'/'))}")).must_equal '/2'
|
242
|
+
body('/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>'/a'))}")).must_equal '/a2'
|
243
|
+
body('/a/b', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('CP'=>'/a/b'))}")).must_equal '/a/b2'
|
244
|
+
|
245
|
+
body('/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('HTTPS'=>'on', 'HTTP_HOST'=>'foo.com', 'CP'=>'a'))}")).must_equal '/a2'
|
246
|
+
body('/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/b', 'HTTPS'=>'on', 'HTTP_HOST'=>'foo.com', 'CP'=>'a'))}")).must_equal '/a2'
|
247
|
+
body('/b/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/b/', 'HTTPS'=>'on', 'HTTP_HOST'=>'foo.com', 'CP'=>'a'))}")).must_equal '/b/a2'
|
248
|
+
body('/b/a', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/b/b', 'HTTPS'=>'on', 'HTTP_HOST'=>'foo.com', 'CP'=>'a'))}")).must_equal '/b/a2'
|
249
|
+
body('/a/b', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/b', 'HTTPS'=>'on', 'HTTP_HOST'=>'foo.com', 'CP'=>'a/b'))}")).must_equal '/a/b2'
|
250
|
+
body('/b/a/b', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/b/', 'HTTPS'=>'on', 'HTTP_HOST'=>'foo.com', 'CP'=>'a/b'))}")).must_equal '/b/a/b2'
|
251
|
+
body('/b/a/b', "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(body('/b/a', 'HTTPS'=>'on', 'HTTP_HOST'=>'foo.com', 'CP'=>'a/b'))}")).must_equal '/b/a/b2'
|
252
|
+
end
|
253
|
+
|
254
|
+
begin
|
255
|
+
require 'rack/csrf'
|
256
|
+
rescue LoadError
|
257
|
+
warn "rack_csrf not installed, skipping route_csrf plugin test for rack_csrf upgrade"
|
258
|
+
else
|
259
|
+
it "supports upgrades from existing rack_csrf token" do
|
260
|
+
route_csrf_app(:upgrade_from_rack_csrf_key=>'csrf.token') do |r|
|
261
|
+
r.get 'clear' do
|
262
|
+
session.clear
|
263
|
+
''
|
264
|
+
end
|
265
|
+
Rack::Csrf.token(env)
|
266
|
+
end
|
267
|
+
app.use Rack::Csrf, :skip=>['POST:/foo', 'POST:/bar'], :raise=>true
|
268
|
+
token = body
|
269
|
+
token.length.wont_equal 84
|
270
|
+
body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'f'
|
271
|
+
body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}")).must_equal 'b'
|
272
|
+
body('/clear').must_equal ''
|
273
|
+
proc{body("/foo", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
274
|
+
proc{body("/bar", "REQUEST_METHOD"=>'POST', 'rack.input'=>StringIO.new("_csrf=#{Rack::Utils.escape(token)}"))}.must_raise Roda::RodaPlugins::RouteCsrf::InvalidToken
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
@@ -187,7 +187,7 @@ describe "typecast_params plugin" do
|
|
187
187
|
end
|
188
188
|
|
189
189
|
it "#int should convert to integer" do
|
190
|
-
tp('a=-1').int('a').must_equal
|
190
|
+
tp('a=-1').int('a').must_equal(-1)
|
191
191
|
tp('a=0').int('a').must_equal 0
|
192
192
|
tp('a=a').int('a').must_equal 0
|
193
193
|
tp.int('a').must_equal 1
|
@@ -248,7 +248,7 @@ describe "typecast_params plugin" do
|
|
248
248
|
end
|
249
249
|
|
250
250
|
it "#Integer should convert to integer strictly" do
|
251
|
-
tp('a=-1').Integer('a').must_equal
|
251
|
+
tp('a=-1').Integer('a').must_equal(-1)
|
252
252
|
tp('a=0').Integer('a').must_equal 0
|
253
253
|
lambda{tp('a=a').Integer('a')}.must_raise @tp_error
|
254
254
|
tp.Integer('a').must_equal 1
|
@@ -277,7 +277,7 @@ describe "typecast_params plugin" do
|
|
277
277
|
end
|
278
278
|
|
279
279
|
it "#float should convert to float" do
|
280
|
-
tp('a=-1').float('a').must_equal
|
280
|
+
tp('a=-1').float('a').must_equal(-1)
|
281
281
|
tp('a=0').float('a').must_equal 0
|
282
282
|
tp('a=a').float('a').must_equal 0
|
283
283
|
tp.float('a').must_equal 1
|
@@ -306,7 +306,7 @@ describe "typecast_params plugin" do
|
|
306
306
|
end
|
307
307
|
|
308
308
|
it "#Float should convert to float strictly" do
|
309
|
-
tp('a=-1').Float('a').must_equal
|
309
|
+
tp('a=-1').Float('a').must_equal(-1)
|
310
310
|
tp('a=0').Float('a').must_equal 0
|
311
311
|
lambda{tp('a=a').Float('a')}.must_raise @tp_error
|
312
312
|
tp.Float('a').must_equal 1
|
@@ -1108,8 +1108,8 @@ describe "typecast_params plugin with customized params" do
|
|
1108
1108
|
end
|
1109
1109
|
|
1110
1110
|
it "should respect custom typecasting methods" do
|
1111
|
-
tp.opp_int('a').must_equal
|
1112
|
-
tp.opp_int!('a').must_equal
|
1111
|
+
tp.opp_int('a').must_equal(-1)
|
1112
|
+
tp.opp_int!('a').must_equal(-1)
|
1113
1113
|
tp.opp_int('d').must_be_nil
|
1114
1114
|
lambda{tp.opp_int!('d')}.must_raise @tp_error
|
1115
1115
|
|
@@ -1133,8 +1133,8 @@ describe "typecast_params plugin with customized params" do
|
|
1133
1133
|
end
|
1134
1134
|
end
|
1135
1135
|
|
1136
|
-
tp.opp_int('a').must_equal
|
1137
|
-
tp.opp_int!('a').must_equal
|
1136
|
+
tp.opp_int('a').must_equal(-1)
|
1137
|
+
tp.opp_int!('a').must_equal(-1)
|
1138
1138
|
tp.opp_int('d').must_be_nil
|
1139
1139
|
lambda{tp.opp_int!('d')}.must_raise @tp_error
|
1140
1140
|
|
data/spec/session_spec.rb
CHANGED
@@ -1,6 +1,8 @@
|
|
1
1
|
require_relative "spec_helper"
|
2
2
|
|
3
3
|
describe "session handling" do
|
4
|
+
include CookieJar
|
5
|
+
|
4
6
|
it "should give a warning if session variable is not available" do
|
5
7
|
app do |r|
|
6
8
|
begin
|
@@ -27,9 +29,9 @@ describe "session handling" do
|
|
27
29
|
|
28
30
|
_, h, b = req
|
29
31
|
b.join.must_equal 'ab'
|
30
|
-
_, h, b = req
|
32
|
+
_, h, b = req
|
31
33
|
b.join.must_equal 'abb'
|
32
|
-
_, h, b = req
|
34
|
+
_, h, b = req
|
33
35
|
b.join.must_equal 'abbb'
|
34
36
|
end
|
35
37
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -24,6 +24,8 @@ end
|
|
24
24
|
|
25
25
|
require_relative "../lib/roda"
|
26
26
|
require "stringio"
|
27
|
+
|
28
|
+
ENV['MT_NO_PLUGINS'] = '1' # Work around stupid autoloading of plugins
|
27
29
|
gem 'minitest'
|
28
30
|
require "minitest/autorun"
|
29
31
|
|
@@ -34,6 +36,23 @@ def (Roda::RodaPlugins).warn(s)
|
|
34
36
|
puts caller.grep(/_spec\.rb:\d+:/)
|
35
37
|
end
|
36
38
|
|
39
|
+
module CookieJar
|
40
|
+
def req(path='/', env={})
|
41
|
+
if path.is_a?(Hash)
|
42
|
+
env = path
|
43
|
+
else
|
44
|
+
env['PATH_INFO'] = path.dup
|
45
|
+
end
|
46
|
+
env['HTTP_COOKIE'] = @cookie if @cookie
|
47
|
+
|
48
|
+
a = super(env)
|
49
|
+
if set = a[1]['Set-Cookie']
|
50
|
+
@cookie = set.sub("; path=/; HttpOnly", '')
|
51
|
+
end
|
52
|
+
a
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
37
56
|
class Minitest::Spec
|
38
57
|
def self.deprecated(a, &block)
|
39
58
|
it("#{a} (deprecated)") do
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: roda
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 3.
|
4
|
+
version: 3.9.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Jeremy Evans
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2018-
|
11
|
+
date: 2018-06-11 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rack
|
@@ -204,6 +204,7 @@ extra_rdoc_files:
|
|
204
204
|
- doc/release_notes/3.5.0.txt
|
205
205
|
- doc/release_notes/3.6.0.txt
|
206
206
|
- doc/release_notes/3.8.0.txt
|
207
|
+
- doc/release_notes/3.9.0.txt
|
207
208
|
files:
|
208
209
|
- CHANGELOG
|
209
210
|
- MIT-LICENSE
|
@@ -254,6 +255,7 @@ files:
|
|
254
255
|
- doc/release_notes/3.6.0.txt
|
255
256
|
- doc/release_notes/3.7.0.txt
|
256
257
|
- doc/release_notes/3.8.0.txt
|
258
|
+
- doc/release_notes/3.9.0.txt
|
257
259
|
- lib/roda.rb
|
258
260
|
- lib/roda/plugins/_symbol_regexp_matchers.rb
|
259
261
|
- lib/roda/plugins/all_verbs.rb
|
@@ -322,6 +324,7 @@ files:
|
|
322
324
|
- lib/roda/plugins/request_aref.rb
|
323
325
|
- lib/roda/plugins/request_headers.rb
|
324
326
|
- lib/roda/plugins/response_request.rb
|
327
|
+
- lib/roda/plugins/route_csrf.rb
|
325
328
|
- lib/roda/plugins/run_append_slash.rb
|
326
329
|
- lib/roda/plugins/run_handler.rb
|
327
330
|
- lib/roda/plugins/shared_vars.rb
|
@@ -419,6 +422,7 @@ files:
|
|
419
422
|
- spec/plugin/request_aref_spec.rb
|
420
423
|
- spec/plugin/request_headers_spec.rb
|
421
424
|
- spec/plugin/response_request_spec.rb
|
425
|
+
- spec/plugin/route_csrf_spec.rb
|
422
426
|
- spec/plugin/run_append_slash_spec.rb
|
423
427
|
- spec/plugin/run_handler_spec.rb
|
424
428
|
- spec/plugin/shared_vars_spec.rb
|
@@ -484,7 +488,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
484
488
|
version: '0'
|
485
489
|
requirements: []
|
486
490
|
rubyforge_project:
|
487
|
-
rubygems_version:
|
491
|
+
rubygems_version: 3.0.0.beta1
|
488
492
|
signing_key:
|
489
493
|
specification_version: 4
|
490
494
|
summary: Routing tree web toolkit
|