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