roda 3.42.0 → 3.46.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2bdd27a69c76000c9c1d2d8f37f9919145fdae55224706c78adec42850c5d21
4
- data.tar.gz: 8b92e1dbc9ec83ebca5d2d5b068c67fa282dfee25fe241451c93228a406a4749
3
+ metadata.gz: 5fd9ea4a9be8072ffc96e5bab91a845e99cc9a9d6641571167badd4146c93318
4
+ data.tar.gz: ed062be1d4b922ba39456f3868e599e3c6134fecfedaa3bfc780667bfec5c5f2
5
5
  SHA512:
6
- metadata.gz: 5c9f2d3f0f021b7b0a16628cefe28c4d4001f2a80804f2bde9b48b641a70482a5d13d699252cb9f7c747de50710b91df30f893f83263e3e73c874621948b4cc6
7
- data.tar.gz: c060f123bfb7bd0f02a83d864169d7d2a021c14cd152b8f3e7641c4c7e0317affb84fce0812afcb0d382e0828e31e2fd97b46482338ae92574fbad97793c8996
6
+ metadata.gz: 59e5677db771776107744f2438505b467363b410d6b2c8d19b1b11e802bfd15b22974997b20185e9ba3a1c9a7b8721bb5e3dbc1481a575f0812300c9c3241cac
7
+ data.tar.gz: 6233ec5251cfbeeaabdb44a7cba75d4831910215ae8239ec19873f58a62f14722bb6b57059f0814ae07e7ccc50977e3a2fbf74bb3b423a253b7dace5773c6887
data/CHANGELOG CHANGED
@@ -1,3 +1,25 @@
1
+ = 3.46.0 (2021-07-12)
2
+
3
+ * Automatically optimize r.on/r.is/r.get/r.post methods with a single string, String, Integer, or regexp argument (jeremyevans)
4
+
5
+ = 3.45.0 (2021-06-14)
6
+
7
+ * Make typecast_params plugin check for null bytes in strings by default, with :allow_null_bytes option for previous behavior (jeremyevans)
8
+
9
+ = 3.44.0 (2021-05-12)
10
+
11
+ * Add optimized_segment_matchers plugin for optimized matchers for a single String class argument (jeremyevans)
12
+
13
+ * Use RFC 5987 UTF-8 and ISO-8859-1 encoded filenames when using send_file and attachment in the sinatra_helpers plugin (jeremyevans)
14
+
15
+ = 3.43.1 (2021-04-13)
16
+
17
+ * [SECURITY] Fix issue where loading content_security_policy plugin after default_headers plugin had no effect (jeremyevans)
18
+
19
+ = 3.43.0 (2021-04-12)
20
+
21
+ * Add host_authorization plugin, for checking that requests are submitted using an approved host (jeremyevans)
22
+
1
23
  = 3.42.0 (2021-03-12)
2
24
 
3
25
  * Make Roda.plugin support plugins using keyword arguments in Ruby 3 (jeremyevans)
data/README.rdoc CHANGED
@@ -13,7 +13,6 @@ Website :: http://roda.jeremyevans.net
13
13
  Source :: http://github.com/jeremyevans/roda
14
14
  Bugs :: http://github.com/jeremyevans/roda/issues
15
15
  Google Group :: http://groups.google.com/group/ruby-roda
16
- IRC :: irc://chat.freenode.net/#roda
17
16
 
18
17
  == Goals
19
18
 
@@ -965,19 +964,17 @@ option. If you really want to turn path checking off, you can do so via the
965
964
  Roda does not ship with integrated support for code reloading, but there are rack-based
966
965
  reloaders that will work with Roda apps.
967
966
 
968
- For most applications, {rack-unreloader}[https://github.com/jeremyevans/rack-unreloader]
969
- is probably the fastest approach to reloading while still being fairly safe, as it
970
- reloads just files that have been modified, and unloads constants defined in the files
971
- before reloading them. However, it requires modifying your application code to use
972
- rack-unreloader specific APIs.
973
-
974
- A similar solution that reloads files and unloads constants is ActiveSupport::Dependencies.
975
- ActiveSupport::Dependencies doesn't require modifying your application code, but it modifies
976
- some core methods, including +require+ and +const_missing+. It requires less configuration,
977
- but depends that you follow Rails' file and class naming conventions. It also provides
978
- autoloading (on the fly) of files when a missing constant is accessed. If your application
979
- does not rely on autoloading then +require_dependency+ must be used to require the dependencies
980
- or they won't be reloaded.
967
+ {Zeitwerk}[https://github.com/fxn/zeitwerk] (which Rails now uses for reloading) can be used
968
+ with Roda. It requires minimal setup and handles most cases. It overrides +require+ when
969
+ activated. If it can meet the needs of your application, it's probably the best approach.
970
+
971
+ {rack-unreloader}[https://github.com/jeremyevans/rack-unreloader] uses a fast
972
+ approach to reloading while still being fairly safe, as it only reloads files that have
973
+ been modified, and unloads constants defined in the files before reloading them. It can handle
974
+ advanced cases that Zeitwerk does not support, such as classes defined in multiple files
975
+ (common when using separate route files for different routing branches in the same application).
976
+ However, rack-unreloader does not modify core classes and using it requires modifying your
977
+ application code to use rack-unreloader specific APIs, which may not be simple.
981
978
 
982
979
  {AutoReloader}[https://github.com/rosenfeld/auto_reloader] provides transparent reloading for
983
980
  all files reached from one of the +reloadable_paths+ option entries, by detecting new top-level
@@ -0,0 +1,34 @@
1
+ = New Features
2
+
3
+ * A host_authorization plugin has been added to verify the requested
4
+ Host header is authorized. Using it can prevent DNS rebinding
5
+ attacks in cases where the application can receive requests for
6
+ arbitrary hosts.
7
+
8
+ To check for authorized hosts in your routing tree, you call the
9
+ check_host_authorization! method. For example, if you want to
10
+ check for authorized hosts after serving requests for public
11
+ files, you could do:
12
+
13
+ plugin :public
14
+ plugin :host_authorization, 'my-domain-name.example.com'
15
+
16
+ route do |r|
17
+ r.public
18
+ check_host_authorized!
19
+
20
+ # ... rest of routing tree
21
+ end
22
+
23
+ In addition to handling single domain names via a string, you can
24
+ provide an array of domain names, a regexp to match again, or a
25
+ proc.
26
+
27
+ By default, requests using unauthorized hosts receive an empty 403
28
+ response. If you would like to customize the response, you can
29
+ pass a block when loading the plugin:
30
+
31
+ plugin :host_authorization, 'my-domain-name.example.com' do |r|
32
+ response.status = 403
33
+ "Response Body Here"
34
+ end
@@ -0,0 +1,23 @@
1
+ = New Features
2
+
3
+ * An optimized_segment_matchers plugin has been added that offers
4
+ very fast matchers for arbitrary segments (the same segments
5
+ that would be matched by the String class matcher). The
6
+ on_segment method it offers accepts no arguments and yields
7
+ the next segment if there is a segment. The is_segment method
8
+ is similar, but only yields if the next segment is the final
9
+ segment.
10
+
11
+ = Other Improvements
12
+
13
+ * The send_file and attachment methods in the sinatra_helpers plugin
14
+ now support RFC 5987 UTF-8 and ISO-8859-1 encoded filenames,
15
+ allowing modern browsers to save files with encoded chracters. For
16
+ older browsers that do not support RFC 5987, unsupported characters
17
+ in filenames are replaced with dashes. This is considered to be an
18
+ improvement over the previous behavior of using Ruby's inspect
19
+ output for the filename, which could contain backslashes (backslash
20
+ is not an allowed chracter in Windows filenames).
21
+
22
+ * The performance of the String class matcher has been slightly
23
+ improved.
@@ -0,0 +1,22 @@
1
+ = Improvements
2
+
3
+ * The typecast_params plugin checks now checks for null bytes by
4
+ default before typecasting. If null bytes are present, it raises
5
+ an error. Most applications do not require null bytes in
6
+ parameters, and in some cases allowing them can lead to security
7
+ issues, especially when parameters are passed to C extensions.
8
+ In general, the benefit of forbidding null bytes in parameters is
9
+ greater than the cost.
10
+
11
+ If you would like to continue allowing null bytes, use the
12
+ :allow_null_bytes option when loading the plugin.
13
+
14
+ Note that this change does not affect uploaded files, since those
15
+ are expected to contain null bytes.
16
+
17
+ = Backwards Compatibility
18
+
19
+ * The change to the typecast_params plugin to raise an error for
20
+ null bytes can break applications that are expecting null bytes
21
+ to be passed in parameters. Such applications should use the
22
+ :allow_null_bytes option when loading the plugin.
@@ -0,0 +1,19 @@
1
+ = Improvements
2
+
3
+ * The r.on, r.is, r.get and r.post methods (and other verb methods
4
+ if using the all_verbs plugin) have now been optimized when using
5
+ a single string or regexp matcher, or the String or Integer class
6
+ matcher. Since those four matchers are the most common types of
7
+ matchers passed to the methods, this can significantly improve
8
+ routing performance (about 50% in the r10k benchmark).
9
+
10
+ This optimization is automatically applied when freezing
11
+ applications, if the related methods have not been modified by
12
+ plugins.
13
+
14
+ This optimization does come at the expense of a small decrease
15
+ in routing performance (3-4%) for unoptimized cases, but the
16
+ majority of applications will see a overall performance benefit
17
+ from this change.
18
+
19
+ * Other minor performance improvements have been made.
data/lib/roda.rb CHANGED
@@ -212,6 +212,10 @@ class Roda
212
212
  if @middleware.empty? && use_new_dispatch_api?
213
213
  plugin :direct_call
214
214
  end
215
+
216
+ if ([:on, :is, :_verb, :_match_class_String, :_match_class_Integer, :_match_string, :_match_regexp, :empty_path?]).all?{|m| self::RodaRequest.instance_method(m).owner == RequestMethods}
217
+ plugin :_optimized_matching
218
+ end
215
219
  end
216
220
 
217
221
  build_rack_app
@@ -0,0 +1,137 @@
1
+ # frozen-string-literal: true
2
+
3
+ #
4
+ class Roda
5
+ module RodaPlugins
6
+ # The _optimized_matching plugin is automatically used internally to speed
7
+ # up matching when a single argument String instance, String class, Integer
8
+ # class, or Regexp matcher is passed to +r.on+, +r.is_+, or a verb method
9
+ # such as +r.get+ or +r.post+.
10
+ #
11
+ # The optimization works by avoiding the +if_match+ method if possible.
12
+ # Instead of clearing the captures array on every call, and having the
13
+ # matching append to the captures, it checks directly for the match,
14
+ # and on succesful match, it yields directly to the block without using
15
+ # the captures array.
16
+ module OptimizedMatching
17
+ TERM = Base::RequestMethods::TERM
18
+
19
+ module RequestMethods
20
+ # Optimize the r.is method handling of a single string, String, Integer,
21
+ # regexp, or true, argument.
22
+ def is(*args, &block)
23
+ case args.length
24
+ when 1
25
+ _is1(args, &block)
26
+ when 0
27
+ always(&block) if @remaining_path.empty?
28
+ else
29
+ if_match(args << TERM, &block)
30
+ end
31
+ end
32
+
33
+ # Optimize the r.on method handling of a single string, String, Integer,
34
+ # or regexp argument. Inline the related matching code to avoid the
35
+ # need to modify @captures.
36
+ def on(*args, &block)
37
+ case args.length
38
+ when 1
39
+ case matcher = args[0]
40
+ when String
41
+ always{yield} if _match_string(matcher)
42
+ when Class
43
+ if matcher == String
44
+ rp = @remaining_path
45
+ if rp.getbyte(0) == 47
46
+ if last = rp.index('/', 1)
47
+ @remaining_path = rp[last, rp.length]
48
+ always{yield rp[1, last-1]}
49
+ elsif (len = rp.length) > 1
50
+ @remaining_path = ""
51
+ always{yield rp[1, len]}
52
+ end
53
+ end
54
+ elsif matcher == Integer
55
+ if matchdata = @remaining_path.match(/\A\/(\d+)(?=\/|\z)/)
56
+ @remaining_path = matchdata.post_match
57
+ always{yield(matchdata[1].to_i)}
58
+ end
59
+ else
60
+ if_match(args, &block)
61
+ end
62
+ when Regexp
63
+ if matchdata = @remaining_path.match(self.class.cached_matcher(matcher){matcher})
64
+ @remaining_path = matchdata.post_match
65
+ always{yield(*matchdata.captures)}
66
+ end
67
+ else
68
+ if_match(args, &block)
69
+ end
70
+ when 0
71
+ always(&block)
72
+ else
73
+ if_match(args, &block)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ # Optimize the r.get/r.post method handling of a single string, String, Integer,
80
+ # regexp, or true, argument.
81
+ def _verb(args, &block)
82
+ case args.length
83
+ when 0
84
+ always(&block)
85
+ when 1
86
+ _is1(args, &block)
87
+ else
88
+ if_match(args << TERM, &block)
89
+ end
90
+ end
91
+
92
+ # Internals of r.is/r.get/r.post optimization. Inline the related matching
93
+ # code to avoid the need to modify @captures.
94
+ def _is1(args, &block)
95
+ case matcher = args[0]
96
+ when String
97
+ rp = @remaining_path
98
+ if _match_string(matcher)
99
+ if @remaining_path.empty?
100
+ always{yield}
101
+ else
102
+ @remaining_path = rp
103
+ nil
104
+ end
105
+ end
106
+ when Class
107
+ if matcher == String
108
+ rp = @remaining_path
109
+ if rp.getbyte(0) == 47 && !rp.index('/', 1) && (len = rp.length) > 1
110
+ @remaining_path = ''
111
+ always{yield rp[1, len]}
112
+ end
113
+ elsif matcher == Integer
114
+ if matchdata = @remaining_path.match(/\A\/(\d+)\z/)
115
+ @remaining_path = ''
116
+ always{yield(matchdata[1].to_i)}
117
+ end
118
+ else
119
+ if_match(args << TERM, &block)
120
+ end
121
+ when Regexp
122
+ if (matchdata = @remaining_path.match(self.class.cached_matcher(matcher){matcher})) && (post_match = matchdata.post_match).empty?
123
+ @remaining_path = ''
124
+ always{yield(*matchdata.captures)}
125
+ end
126
+ when true
127
+ always(&block) if @remaining_path.empty?
128
+ else
129
+ if_match(args << TERM, &block)
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ register_plugin(:_optimized_matching, OptimizedMatching)
136
+ end
137
+ end
@@ -152,6 +152,22 @@ class Roda
152
152
  # together and not compressed during compilation. You can use the
153
153
  # :css_compressor and :js_compressor options to specify the compressor to use.
154
154
  #
155
+ # It is also possible to use the built-in compression options in the CSS or JS
156
+ # compiler, assuming the compiler supports such options. For example, with
157
+ # sass/sassc, you can use:
158
+ #
159
+ # plugin :assets,
160
+ # css_opts: {style: :compressed}
161
+ #
162
+ # === Source Maps (CSS)
163
+ #
164
+ # The assets plugin does not have direct support for source maps, so it is
165
+ # recommended you use embedded source maps if supported by the CSS compiler.
166
+ # For sass/sassc, you can use:
167
+ #
168
+ # plugin :assets,
169
+ # css_opts: {:source_map_embed=>true, source_map_contents: true, source_map_file: "."}
170
+ #
155
171
  # === With Asset Groups
156
172
  #
157
173
  # When using asset groups, a separate compiled file will be produced per
@@ -62,8 +62,7 @@ class Roda
62
62
  end
63
63
 
64
64
  # Configure whether to append or overwrite if content_for
65
- # is called multiple times to set data. Overwrite is default, use
66
- # the :append option to append.
65
+ # is called multiple times with the same key.
67
66
  def self.configure(app, opts = OPTS)
68
67
  app.opts[:append_content_for] = opts.fetch(:append, true)
69
68
  end
@@ -23,30 +23,31 @@ class Roda
23
23
  module DefaultHeaders
24
24
  # Merge the given headers into the existing default headers, if any.
25
25
  def self.configure(app, headers={})
26
- headers = app.opts[:default_headers] = (app.default_headers || app::RodaResponse::DEFAULT_HEADERS).merge(headers).freeze
26
+ app.opts[:default_headers] = (app.default_headers || app::RodaResponse::DEFAULT_HEADERS).merge(headers).freeze
27
+ end
28
+
29
+ module ClassMethods
30
+ # The default response headers to use for the current class.
31
+ def default_headers
32
+ opts[:default_headers]
33
+ end
27
34
 
28
- if headers.all?{|k, v| k.is_a?(String) && v.is_a?(String)}
29
- response_class = app::RodaResponse
30
- owner = response_class.instance_method(:set_default_headers).owner
31
- if owner == Base::ResponseMethods || (owner == response_class && app.opts[:set_default_headers_overridder] == response_class)
32
- app.opts[:set_default_headers_overridder] = response_class
33
- response_class.class_eval(<<-END, __FILE__, __LINE__+1)
35
+ # Optimize the response class set_default_headers method if it hasn't been
36
+ # overridden and all default headers are strings.
37
+ def freeze
38
+ if (headers = opts[:default_headers]).all?{|k, v| k.is_a?(String) && v.is_a?(String)} &&
39
+ (self::RodaResponse.instance_method(:set_default_headers).owner == Base::ResponseMethods)
40
+ self::RodaResponse.class_eval(<<-END, __FILE__, __LINE__+1)
34
41
  private
35
42
 
36
- alias set_default_headers set_default_headers
37
43
  def set_default_headers
38
44
  h = @headers
39
45
  #{headers.map{|k,v| "h[#{k.inspect}] ||= #{v.inspect}"}.join('; ')}
40
46
  end
41
47
  END
42
48
  end
43
- end
44
- end
45
49
 
46
- module ClassMethods
47
- # The default response headers to use for the current class.
48
- def default_headers
49
- opts[:default_headers]
50
+ super
50
51
  end
51
52
  end
52
53
 
@@ -0,0 +1,156 @@
1
+ # frozen-string-literal: true
2
+
3
+ #
4
+ class Roda
5
+ module RodaPlugins
6
+ # The host_authorization plugin allows configuring an authorized host or
7
+ # an array of authorized hosts. Then in the routing tree, you can check
8
+ # whether the request uses an authorized host via the +check_host_authorized!+
9
+ # method.
10
+ #
11
+ # If the request doesn't match one of the authorized hosts, the
12
+ # request processing stops at that point. Using this plugin can prevent
13
+ # DNS rebinding attacks if the application can receive requests for
14
+ # arbitrary hosts.
15
+ #
16
+ # By default, an empty response using status 403 will be returned for requests
17
+ # with unauthorized hosts.
18
+ #
19
+ # Because +check_host_authorized!+ is an instance method, you can easily choose
20
+ # to only check for authorization in certain routes, or to check it after
21
+ # other processing. For example, you could check for authorized hosts after
22
+ # serving static files, since the serving of static files should not be
23
+ # vulnerable to DNS rebinding attacks.
24
+ #
25
+ # = Usage
26
+ #
27
+ # In your routing tree, call the +check_host_authorized!+ method at the point you
28
+ # want to check for authorized hosts:
29
+ #
30
+ # plugin :host_authorization, 'www.example.com'
31
+ # plugin :public
32
+ #
33
+ # route do |r|
34
+ # r.public
35
+ # check_host_authorized!
36
+ #
37
+ # # ...
38
+ # end
39
+ #
40
+ # = Specifying authorized hosts
41
+ #
42
+ # For applications hosted on a single domain name, you can use a single string:
43
+ #
44
+ # plugin :host_authorization, 'www.example.com'
45
+ #
46
+ # For applications hosted on multiple domain names, you can use an array of strings:
47
+ #
48
+ # plugin :host_authorization, %w'www.example.com www.example2.com'
49
+ #
50
+ # For applications supporting arbitrary subdomains, you can use a regexp. If using
51
+ # a regexp, make sure you use <tt>\A<tt> and <tt>\z</tt> in your regexp, and restrict
52
+ # the allowed characters to the minimum required, otherwise you can potentionally
53
+ # introduce a security issue:
54
+ #
55
+ # plugin :host_authorization, /\A[-0-9a-f]+\.example\.com\z/
56
+ #
57
+ # For applications with more complex requirements, you can use a proc. Similarly
58
+ # to the regexp case, the proc should be aware the host contains user-submitted
59
+ # values, and not assume it is in any particular format:
60
+ #
61
+ # plugin :host_authorization, proc{|host| ExternalService.allowed_host?(host)}
62
+ #
63
+ # If an array of values is passed as the host argument, the host is authorized if
64
+ # it matches any value in the array. All host authorization checks use the
65
+ # <tt>===</tt> method, which is why it works for strings, regexps, and procs.
66
+ # It can also work with arbitrary objects that support <tt>===</tt>.
67
+ #
68
+ # For security reasons, only the +Host+ header is checked by default. If you are
69
+ # sure that your application is being run behind a forwarding proxy that sets the
70
+ # <tt>X-Forwarded-Host</tt> header, you should enable support for checking that
71
+ # header using the +:check_forwarded+ option:
72
+ #
73
+ # plugin :host_authorization, 'www.example.com', check_forwarded: true
74
+ #
75
+ # In this case, the trailing host in the <tt>X-Forwarded-Host</tt> header is checked,
76
+ # which should be the host set by the forwarding proxy closest to the application.
77
+ # In cases where multiple forwarding proxies are used that append to the
78
+ # <tt>X-Forwarded-Host</tt> header, you should not use this plugin.
79
+ #
80
+ # = Customizing behavior
81
+ #
82
+ # By default, an unauthorized host will receive an empty 403 response. You can
83
+ # customize this by passing a block when loading the plugin. For example, for
84
+ # sites using the render plugin, you could return a page that uses your default
85
+ # layout:
86
+ #
87
+ # plugin :render
88
+ # plugin :host_authorization, 'www.example.com' do |r|
89
+ # response.status = 403
90
+ # view(:content=>"<h1>Forbidden</h1>")
91
+ # end
92
+ #
93
+ # The block passed to this plugin is treated as a match block.
94
+ module HostAuthorization
95
+ def self.configure(app, host, opts=OPTS, &block)
96
+ app.opts[:host_authorization_host] = host
97
+ app.opts[:host_authorization_check_forwarded] = opts[:check_forwarded] if opts.key?(:check_forwarded)
98
+
99
+ if block
100
+ app.define_roda_method(:host_authorization_unauthorized, 1, &block)
101
+ end
102
+ end
103
+
104
+ module InstanceMethods
105
+ # Check whether the host is authorized. If not authorized, return a response
106
+ # immediately based on the plugin block.
107
+ def check_host_authorization!
108
+ r = @_request
109
+ return if host_authorized?(_convert_host_for_authorization(r.env["HTTP_HOST"].to_s.dup))
110
+
111
+ if opts[:host_authorization_check_forwarded] && (host = r.env["HTTP_X_FORWARDED_HOST"])
112
+ if i = host.rindex(',')
113
+ host = host[i+1, 10000000].to_s
114
+ end
115
+ host = _convert_host_for_authorization(host.strip)
116
+
117
+ if !host.empty? && host_authorized?(host)
118
+ return
119
+ end
120
+ end
121
+
122
+ r.on do
123
+ host_authorization_unauthorized(r)
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ # Remove the port information from the passed string (mutates the passed argument).
130
+ def _convert_host_for_authorization(host)
131
+ host.sub!(/:\d+\z/, "")
132
+ host
133
+ end
134
+
135
+ # Whether the host given is one of the authorized hosts for this application.
136
+ def host_authorized?(host, authorized_host = opts[:host_authorization_host])
137
+ case authorized_host
138
+ when Array
139
+ authorized_host.any?{|auth_host| host_authorized?(host, auth_host)}
140
+ else
141
+ authorized_host === host
142
+ end
143
+ end
144
+
145
+ # Action to take for unauthorized hosts. Sets a 403 status by default.
146
+ def host_authorization_unauthorized(_)
147
+ @_response.status = 403
148
+ nil
149
+ end
150
+ end
151
+ end
152
+
153
+ register_plugin(:host_authorization, HostAuthorization)
154
+ end
155
+ end
156
+
@@ -0,0 +1,53 @@
1
+ # frozen-string-literal: true
2
+
3
+ #
4
+ class Roda
5
+ module RodaPlugins
6
+ # The optimized_segment_matchers plugin adds two optimized matcher methods,
7
+ # +r.on_segment+ and +r.is_segment+. +r.on_segment+ is an optimized version of
8
+ # +r.on String+ that accepts no arguments and yields the next segment if there
9
+ # is a segment. +r.is_segment+ is an optimized version of +r.is String+ that accepts
10
+ # no arguments and yields the next segment only if it is the last segment.
11
+ #
12
+ # plugin :optimized_segment_matchers
13
+ #
14
+ # route do |r|
15
+ # r.on_segment do |x|
16
+ # # matches any segment (e.g. /a, /b, but not /)
17
+ # r.is_segment do |y|
18
+ # # matches only if final segment (e.g. /a/b, /b/c, but not /c, /c/d/, /c/d/e)
19
+ # end
20
+ # end
21
+ # end
22
+ module OptimizedSegmentMatchers
23
+ module RequestMethods
24
+ # Optimized version of +r.on String+ that yields the next segment if there
25
+ # is a segment.
26
+ def on_segment
27
+ rp = @remaining_path
28
+ if rp.getbyte(0) == 47
29
+ if last = rp.index('/', 1)
30
+ @remaining_path = rp[last, rp.length]
31
+ always{yield rp[1, last-1]}
32
+ elsif (len = rp.length) > 1
33
+ @remaining_path = ""
34
+ always{yield rp[1, len]}
35
+ end
36
+ end
37
+ end
38
+
39
+ # Optimized version of +r.is String+ that yields the next segment only if it
40
+ # is the final segment.
41
+ def is_segment
42
+ rp = @remaining_path
43
+ if rp.getbyte(0) == 47 && !rp.index('/', 1) && (len = rp.length) > 1
44
+ @remaining_path = ""
45
+ always{yield rp[1, len]}
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ register_plugin(:optimized_segment_matchers, OptimizedSegmentMatchers)
52
+ end
53
+ end
@@ -19,7 +19,8 @@ class Roda
19
19
  # end
20
20
  # end
21
21
  #
22
- # Note that both of these methods only work with plain strings, not
22
+ # If you are using the placeholder_string_matchers plugin, note
23
+ # that both of these methods only work with plain strings, not
23
24
  # with strings with embedded colons for capturing. Matching will work
24
25
  # correctly in such cases, but the captures will not be yielded to the
25
26
  # match blocks.
@@ -211,6 +211,10 @@ class Roda
211
211
  # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
212
212
  # OTHER DEALINGS IN THE SOFTWARE.
213
213
  module SinatraHelpers
214
+ UTF8_ENCODING = Encoding.find('UTF-8')
215
+ ISO88591_ENCODING = Encoding.find('ISO-8859-1')
216
+ BINARY_ENCODING = Encoding.find('BINARY')
217
+
214
218
  # Depend on the status_303 plugin.
215
219
  def self.load_dependencies(app, _opts = nil)
216
220
  app.plugin :status_303
@@ -432,7 +436,25 @@ class Roda
432
436
  # instructing the user agents to prompt to save.
433
437
  def attachment(filename = nil, disposition='attachment')
434
438
  if filename
435
- params = "; filename=#{File.basename(filename).inspect}"
439
+ param_filename = File.basename(filename)
440
+ encoding = param_filename.encoding
441
+
442
+ needs_encoding = param_filename.gsub!(/[^ 0-9a-zA-Z!\#$&\+\.\^_`\|~]+/, '-')
443
+ params = "; filename=#{param_filename.inspect}"
444
+
445
+ if needs_encoding && (encoding == UTF8_ENCODING || encoding == ISO88591_ENCODING)
446
+ # File name contains non attr-char characters from RFC 5987 Section 3.2.1
447
+
448
+ encoded_filename = File.basename(filename).force_encoding(BINARY_ENCODING)
449
+ # Similar regexp as above, but treat each byte separately, and encode
450
+ # space characters, since those aren't allowed in attr-char
451
+ encoded_filename.gsub!(/[^0-9a-zA-Z!\#$&\+\.\^_`\|~]/) do |c|
452
+ "%%%X" % c.ord
453
+ end
454
+
455
+ encoded_params = "; filename*=#{encoding.to_s}''#{encoded_filename}"
456
+ end
457
+
436
458
  unless @headers["Content-Type"]
437
459
  ext = File.extname(filename)
438
460
  unless ext.empty?
@@ -440,7 +462,7 @@ class Roda
440
462
  end
441
463
  end
442
464
  end
443
- @headers["Content-Disposition"] = "#{disposition}#{params}"
465
+ @headers["Content-Disposition"] = "#{disposition}#{params}#{encoded_params}"
444
466
  end
445
467
 
446
468
  # Whether or not the status is set to 1xx. Returns nil if status not yet set.
@@ -260,6 +260,11 @@ class Roda
260
260
  # strip leading and trailing whitespace from parameter string values before processing, which
261
261
  # you can do by passing the <tt>strip: :all</tt> option when loading the plugin.
262
262
  #
263
+ # By default, the typecast_params conversion procs check that null bytes are not allowed
264
+ # in param string values. This check for null bytes occurs prior to any type conversion.
265
+ # If you would like to skip this check and allow null bytes in param string values,
266
+ # you can do by passing the <tt>:allow_null_bytes</tt> option when loading the plugin.
267
+ #
263
268
  # By design, typecast_params only deals with string keys, it is not possible to use
264
269
  # symbol keys as arguments to the conversion methods and have them converted.
265
270
  module TypecastParams
@@ -356,6 +361,14 @@ class Roda
356
361
  end
357
362
  end
358
363
 
364
+ module AllowNullByte
365
+ private
366
+
367
+ # Allow ASCII NUL bytes ("\0") in parameter string values.
368
+ def check_null_byte(v)
369
+ end
370
+ end
371
+
359
372
  module StringStripper
360
373
  private
361
374
 
@@ -391,7 +404,10 @@ class Roda
391
404
  convert_array_meth = :"_convert_array_#{type}"
392
405
  define_method(convert_array_meth) do |v|
393
406
  raise Error, "expected array but received #{v.inspect}" unless v.is_a?(Array)
394
- v.map!{|val| send(convert_meth, val)}
407
+ v.map! do |val|
408
+ check_null_byte(val)
409
+ send(convert_meth, val)
410
+ end
395
411
  end
396
412
 
397
413
  private convert_meth, convert_array_meth
@@ -927,12 +943,20 @@ class Roda
927
943
  end
928
944
  end
929
945
 
946
+ # Raise an Error if the value is a string containing a null byte.
947
+ def check_null_byte(v)
948
+ if v.is_a?(String) && v.index("\0")
949
+ handle_error(nil, :null_byte, "string parameter contains null byte", true)
950
+ end
951
+ end
952
+
930
953
  # Get the value of +key+ for the object, and convert it to the expected type using +meth+.
931
954
  # If the value either before or after conversion is nil, return the +default+ value.
932
955
  def process(meth, key, default)
933
956
  v = param_value(key)
934
957
 
935
958
  unless v.nil?
959
+ check_null_byte(v)
936
960
  v = send(meth, v)
937
961
  end
938
962
 
@@ -992,6 +1016,9 @@ class Roda
992
1016
  if opts[:strip] == :all
993
1017
  app::TypecastParams.send(:include, StringStripper)
994
1018
  end
1019
+ if opts[:allow_null_bytes]
1020
+ app::TypecastParams.send(:include, AllowNullByte)
1021
+ end
995
1022
  end
996
1023
 
997
1024
  module ClassMethods
data/lib/roda/request.rb CHANGED
@@ -24,7 +24,7 @@ class Roda
24
24
 
25
25
  # Return the cached pattern for the given object. If the object is
26
26
  # not already cached, yield to get the basic pattern, and convert the
27
- # basic pattern to a pattern that does not partial segments.
27
+ # basic pattern to a pattern that does not match partial segments.
28
28
  def cached_matcher(obj)
29
29
  cache = @match_pattern_cache
30
30
 
@@ -234,9 +234,7 @@ class Roda
234
234
 
235
235
  # An alias of remaining_path. If a plugin changes remaining_path then
236
236
  # it should override this method to return the untouched original.
237
- def real_remaining_path
238
- remaining_path
239
- end
237
+ alias real_remaining_path remaining_path
240
238
 
241
239
  # Match POST requests. If no arguments are provided, matches all POST
242
240
  # requests, otherwise, matches only POST requests where the arguments
@@ -336,7 +334,7 @@ class Roda
336
334
  # Use <tt>r.get true</tt> to handle +GET+ requests where the current
337
335
  # path is empty.
338
336
  def root(&block)
339
- if remaining_path == "/" && is_get?
337
+ if @remaining_path == "/" && is_get?
340
338
  always(&block)
341
339
  end
342
340
  end
@@ -468,8 +466,8 @@ class Roda
468
466
  if last = rp.index('/', 1)
469
467
  @captures << rp[1, last-1]
470
468
  @remaining_path = rp[last, rp.length]
471
- elsif rp.length > 1
472
- @captures << rp[1,rp.length]
469
+ elsif (len = rp.length) > 1
470
+ @captures << rp[1, len]
473
471
  @remaining_path = ""
474
472
  end
475
473
  end
@@ -519,7 +517,7 @@ class Roda
519
517
  # SCRIPT_NAME to include the matched path, removes the matched
520
518
  # path from PATH_INFO, and updates captures with any regex captures.
521
519
  def consume(pattern)
522
- if matchdata = remaining_path.match(pattern)
520
+ if matchdata = @remaining_path.match(pattern)
523
521
  @remaining_path = matchdata.post_match
524
522
  captures = matchdata.captures
525
523
  captures = yield(*captures) if block_given?
@@ -548,7 +546,7 @@ class Roda
548
546
 
549
547
  # Whether the current path is considered empty.
550
548
  def empty_path?
551
- remaining_path.empty?
549
+ @remaining_path.empty?
552
550
  end
553
551
 
554
552
  # If all of the arguments match, yields to the match block and
data/lib/roda/version.rb CHANGED
@@ -4,7 +4,7 @@ class Roda
4
4
  RodaMajorVersion = 3
5
5
 
6
6
  # The minor version of Roda, updated for new feature releases of Roda.
7
- RodaMinorVersion = 42
7
+ RodaMinorVersion = 46
8
8
 
9
9
  # The patch version of Roda, updated only for bug fixes from the last
10
10
  # feature release.
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.42.0
4
+ version: 3.46.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: 2021-03-12 00:00:00.000000000 Z
11
+ date: 2021-07-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack
@@ -213,6 +213,10 @@ extra_rdoc_files:
213
213
  - doc/release_notes/3.40.0.txt
214
214
  - doc/release_notes/3.41.0.txt
215
215
  - doc/release_notes/3.42.0.txt
216
+ - doc/release_notes/3.43.0.txt
217
+ - doc/release_notes/3.44.0.txt
218
+ - doc/release_notes/3.45.0.txt
219
+ - doc/release_notes/3.46.0.txt
216
220
  - doc/release_notes/3.5.0.txt
217
221
  - doc/release_notes/3.6.0.txt
218
222
  - doc/release_notes/3.7.0.txt
@@ -262,6 +266,10 @@ files:
262
266
  - doc/release_notes/3.40.0.txt
263
267
  - doc/release_notes/3.41.0.txt
264
268
  - doc/release_notes/3.42.0.txt
269
+ - doc/release_notes/3.43.0.txt
270
+ - doc/release_notes/3.44.0.txt
271
+ - doc/release_notes/3.45.0.txt
272
+ - doc/release_notes/3.46.0.txt
265
273
  - doc/release_notes/3.5.0.txt
266
274
  - doc/release_notes/3.6.0.txt
267
275
  - doc/release_notes/3.7.0.txt
@@ -272,6 +280,7 @@ files:
272
280
  - lib/roda/plugins.rb
273
281
  - lib/roda/plugins/_after_hook.rb
274
282
  - lib/roda/plugins/_before_hook.rb
283
+ - lib/roda/plugins/_optimized_matching.rb
275
284
  - lib/roda/plugins/_symbol_regexp_matchers.rb
276
285
  - lib/roda/plugins/all_verbs.rb
277
286
  - lib/roda/plugins/assets.rb
@@ -312,6 +321,7 @@ files:
312
321
  - lib/roda/plugins/header_matchers.rb
313
322
  - lib/roda/plugins/heartbeat.rb
314
323
  - lib/roda/plugins/hooks.rb
324
+ - lib/roda/plugins/host_authorization.rb
315
325
  - lib/roda/plugins/indifferent_params.rb
316
326
  - lib/roda/plugins/json.rb
317
327
  - lib/roda/plugins/json_parser.rb
@@ -330,6 +340,7 @@ files:
330
340
  - lib/roda/plugins/named_templates.rb
331
341
  - lib/roda/plugins/not_allowed.rb
332
342
  - lib/roda/plugins/not_found.rb
343
+ - lib/roda/plugins/optimized_segment_matchers.rb
333
344
  - lib/roda/plugins/optimized_string_matchers.rb
334
345
  - lib/roda/plugins/padrino_render.rb
335
346
  - lib/roda/plugins/param_matchers.rb
@@ -401,7 +412,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
401
412
  - !ruby/object:Gem::Version
402
413
  version: '0'
403
414
  requirements: []
404
- rubygems_version: 3.2.3
415
+ rubygems_version: 3.2.22
405
416
  signing_key:
406
417
  specification_version: 4
407
418
  summary: Routing tree web toolkit