otto 1.1.0.pre.alpha4 → 1.2.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.
data/lib/otto.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require 'logger'
2
+ require 'securerandom'
2
3
 
3
4
  require 'rack/request'
4
5
  require 'rack/response'
@@ -10,9 +11,30 @@ require_relative 'otto/static'
10
11
  require_relative 'otto/helpers/request'
11
12
  require_relative 'otto/helpers/response'
12
13
  require_relative 'otto/version'
14
+ require_relative 'otto/security/config'
15
+ require_relative 'otto/security/csrf'
16
+ require_relative 'otto/security/validator'
13
17
 
14
18
  # Otto is a simple Rack router that allows you to define routes in a file
19
+ # with built-in security features including CSRF protection, input validation,
20
+ # and trusted proxy support.
15
21
  #
22
+ # Basic usage:
23
+ # otto = Otto.new('routes.txt')
24
+ #
25
+ # With security features:
26
+ # otto = Otto.new('routes.txt', {
27
+ # csrf_protection: true,
28
+ # request_validation: true,
29
+ # trusted_proxies: ['10.0.0.0/8']
30
+ # })
31
+ #
32
+ # Security headers are applied conservatively by default (only basic headers
33
+ # like X-Content-Type-Options). Restrictive headers like HSTS, CSP, and
34
+ # X-Frame-Options must be enabled explicitly:
35
+ # otto.enable_hsts!
36
+ # otto.enable_csp!
37
+ # otto.enable_frame_protection!
16
38
  #
17
39
  class Otto
18
40
  LIB_HOME = __dir__ unless defined?(Otto::LIB_HOME)
@@ -20,91 +42,137 @@ class Otto
20
42
  @debug = ENV['OTTO_DEBUG'] == 'true'
21
43
  @logger = Logger.new($stdout, Logger::INFO)
22
44
 
23
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions
24
- attr_reader :option, :static_route
25
- attr_accessor :not_found, :server_error
45
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config
46
+ attr_accessor :not_found, :server_error, :middleware_stack
26
47
 
27
- def initialize path=nil, opts={}
48
+ def initialize(path = nil, opts = {})
28
49
  @routes_static = { GET: {} }
29
50
  @routes = { GET: [] }
30
51
  @routes_literal = { GET: {} }
31
52
  @route_definitions = {}
32
- @option = opts.merge({
53
+ @option = {
33
54
  public: nil,
34
55
  locale: 'en'
35
- })
56
+ }.merge(opts)
57
+ @security_config = Otto::Security::Config.new
58
+ @middleware_stack = []
59
+
60
+ # Configure security based on options
61
+ configure_security(opts)
62
+
36
63
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
37
64
  load(path) unless path.nil?
38
65
  super()
39
66
  end
40
- alias_method :options, :option
67
+ alias options option
41
68
 
42
- def load path
69
+ def load(path)
43
70
  path = File.expand_path(path)
44
71
  raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
72
+
45
73
  raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip.split(/\s+/) }
46
- raw.each { |entry|
47
- begin
48
- verb, path, definition = *entry
49
- route = Otto::Route.new verb, path, definition
50
- route.otto = self
51
- path_clean = path.gsub /\/$/, ''
52
- @route_definitions[route.definition] = route
53
- Otto.logger.debug "route: #{route.pattern}" if Otto.debug
54
- @routes[route.verb] ||= []
55
- @routes[route.verb] << route
56
- @routes_literal[route.verb] ||= {}
57
- @routes_literal[route.verb][path_clean] = route
58
-
59
- rescue StandardError => ex
60
- Otto.logger.error "Bad route in #{path}: #{entry}"
61
- end
62
- }
74
+ raw.each do |entry|
75
+ verb, path, definition = *entry
76
+ route = Otto::Route.new verb, path, definition
77
+ route.otto = self
78
+ path_clean = path.gsub(%r{/$}, '')
79
+ @route_definitions[route.definition] = route
80
+ Otto.logger.debug "route: #{route.pattern}" if Otto.debug
81
+ @routes[route.verb] ||= []
82
+ @routes[route.verb] << route
83
+ @routes_literal[route.verb] ||= {}
84
+ @routes_literal[route.verb][path_clean] = route
85
+ rescue StandardError
86
+ Otto.logger.error "Bad route in #{path}: #{entry}"
87
+ end
63
88
  self
64
89
  end
65
90
 
66
- def safe_file? path
67
- globstr = File.join(option[:public], '*')
68
- pathstr = File.join(option[:public], path)
69
- File.fnmatch?(globstr, pathstr) && (File.owned?(pathstr) || File.grpowned?(pathstr)) && File.readable?(pathstr) && !File.directory?(pathstr)
91
+ def safe_file?(path)
92
+ return false if option[:public].nil? || option[:public].empty?
93
+ return false if path.nil? || path.empty?
94
+
95
+ # Normalize and resolve the public directory path
96
+ public_dir = File.expand_path(option[:public])
97
+ return false unless File.directory?(public_dir)
98
+
99
+ # Clean the requested path - remove null bytes and normalize
100
+ clean_path = path.gsub("\0", "").strip
101
+ return false if clean_path.empty?
102
+
103
+ # Join and expand to get the full resolved path
104
+ requested_path = File.expand_path(File.join(public_dir, clean_path))
105
+
106
+ # Ensure the resolved path is within the public directory (prevents path traversal)
107
+ return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
108
+
109
+ # Check file exists, is readable, and is not a directory
110
+ File.exist?(requested_path) &&
111
+ File.readable?(requested_path) &&
112
+ !File.directory?(requested_path) &&
113
+ (File.owned?(requested_path) || File.grpowned?(requested_path))
114
+ end
115
+
116
+ def safe_dir?(path)
117
+ return false if path.nil? || path.empty?
118
+
119
+ # Clean and expand the path
120
+ clean_path = path.gsub("\0", "").strip
121
+ return false if clean_path.empty?
122
+
123
+ expanded_path = File.expand_path(clean_path)
124
+
125
+ # Check directory exists, is readable, and has proper ownership
126
+ File.directory?(expanded_path) &&
127
+ File.readable?(expanded_path) &&
128
+ (File.owned?(expanded_path) || File.grpowned?(expanded_path))
70
129
  end
71
130
 
72
- def safe_dir? path
73
- (File.owned?(path) || File.grpowned?(path)) && File.directory?(path)
131
+ def add_static_path(path)
132
+ return unless safe_file?(path)
133
+
134
+ base_path = File.split(path).first
135
+ # Files in the root directory can refer to themselves
136
+ base_path = path if base_path == '/'
137
+ File.join(option[:public], base_path)
138
+ Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
139
+ routes_static[:GET][base_path] = base_path
74
140
  end
75
141
 
76
- def add_static_path path
77
- if safe_file?(path)
78
- base_path = File.split(path).first
79
- # Files in the root directory can refer to themselves
80
- base_path = path if base_path == '/'
81
- static_path = File.join(option[:public], base_path)
82
- Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
83
- routes_static[:GET][base_path] = base_path
142
+ def call(env)
143
+ # Apply middleware stack
144
+ app = lambda { |e| handle_request(e) }
145
+ @middleware_stack.reverse.each do |middleware|
146
+ app = middleware.new(app, @security_config)
147
+ end
148
+
149
+ begin
150
+ app.call(env)
151
+ rescue StandardError => e
152
+ handle_error(e, env)
84
153
  end
85
154
  end
86
155
 
87
- def call env
156
+ def handle_request(env)
88
157
  locale = determine_locale env
89
158
  env['rack.locale'] = locale
90
- if option[:public] && safe_dir?(option[:public])
91
- @static_route ||= Rack::File.new(option[:public])
92
- end
159
+ @static_route ||= Rack::Files.new(option[:public]) if option[:public] && safe_dir?(option[:public])
93
160
  path_info = Rack::Utils.unescape(env['PATH_INFO'])
94
161
  path_info = '/' if path_info.to_s.empty?
95
162
 
96
163
  begin
97
164
  path_info_clean = path_info
98
- .encode(
99
- 'UTF-8', # Target encoding
100
- invalid: :replace, # Replace invalid byte sequences
101
- undef: :replace, # Replace characters undefined in UTF-8
102
- replace: '' # Use empty string for replacement
103
- )
104
- .gsub(/\/$/, '') # Remove trailing slash, if present
105
- rescue ArgumentError => ex
106
- # Log the error
107
- Otto.logger.error "[Otto.call] Error cleaning `#{path_info}`: #{ex.message}"
165
+ .encode(
166
+ 'UTF-8', # Target encoding
167
+ invalid: :replace, # Replace invalid byte sequences
168
+ undef: :replace, # Replace characters undefined in UTF-8
169
+ replace: '' # Use empty string for replacement
170
+ )
171
+ .gsub(%r{/$}, '') # Remove trailing slash, if present
172
+ rescue ArgumentError => e
173
+ # Log the error but don't expose details
174
+ Otto.logger.error "[Otto.handle_request] Path encoding error"
175
+ Otto.logger.debug "[Otto.handle_request] Error details: #{e.message}" if Otto.debug
108
176
  # Set a default value or use the original path_info
109
177
  path_info_clean = path_info
110
178
  end
@@ -116,49 +184,47 @@ class Otto
116
184
  literal_routes = routes_literal[http_verb] || {}
117
185
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
118
186
  if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
119
- #Otto.logger.debug " request: #{path_info} (static)"
187
+ # Otto.logger.debug " request: #{path_info} (static)"
120
188
  static_route.call(env)
121
189
  elsif literal_routes.has_key?(path_info_clean)
122
190
  route = literal_routes[path_info_clean]
123
- #Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
191
+ # Otto.logger.debug " request: #{http_verb} #{path_info} (literal route: #{route.verb} #{route.path})"
124
192
  route.call(env)
125
193
  elsif static_route && http_verb == :GET && safe_file?(path_info)
126
- static_path = File.join(option[:public], base_path)
127
- Otto.logger.debug " new static route: #{base_path} (#{path_info})"
194
+ Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
128
195
  routes_static[:GET][base_path] = base_path
129
196
  static_route.call(env)
130
197
  else
131
198
  extra_params = {}
132
199
  found_route = nil
133
200
  valid_routes = routes[http_verb] || []
134
- valid_routes.push *routes[:GET] if http_verb == :HEAD
135
- valid_routes.each { |route|
136
- #Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
137
- if (match = route.pattern.match(path_info))
138
- values = match.captures.to_a
139
- # The first capture returned is the entire matched string b/c
140
- # we wrapped the entire regex in parens. We don't need it to
141
- # the full match.
142
- full_match = values.shift
143
- extra_params =
144
- if route.keys.any?
145
- route.keys.zip(values).inject({}) do |hash,(k,v)|
146
- if k == 'splat'
147
- (hash[k] ||= []) << v
148
- else
149
- hash[k] = v
150
- end
151
- hash
201
+ valid_routes.push(*routes[:GET]) if http_verb == :HEAD
202
+ valid_routes.each do |route|
203
+ # Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
204
+ next unless (match = route.pattern.match(path_info))
205
+
206
+ values = match.captures.to_a
207
+ # The first capture returned is the entire matched string b/c
208
+ # we wrapped the entire regex in parens. We don't need it to
209
+ # the full match.
210
+ values.shift
211
+ extra_params =
212
+ if route.keys.any?
213
+ route.keys.zip(values).each_with_object({}) do |(k, v), hash|
214
+ if k == 'splat'
215
+ (hash[k] ||= []) << v
216
+ else
217
+ hash[k] = v
152
218
  end
153
- elsif values.any?
154
- {'captures' => values}
155
- else
156
- {}
157
219
  end
158
- found_route = route
159
- break
160
- end
161
- }
220
+ elsif values.any?
221
+ { 'captures' => values }
222
+ else
223
+ {}
224
+ end
225
+ found_route = route
226
+ break
227
+ end
162
228
  found_route ||= literal_routes['/404']
163
229
  if found_route
164
230
  found_route.call env, extra_params
@@ -166,14 +232,6 @@ class Otto
166
232
  @not_found || Otto::Static.not_found
167
233
  end
168
234
  end
169
- rescue => ex
170
- Otto.logger.error "#{ex.class}: #{ex.message} #{ex.backtrace.join("\n")}"
171
-
172
- if found_route = literal_routes['/500']
173
- found_route.call env
174
- else
175
- @server_error || Otto::Static.server_error
176
- end
177
235
  end
178
236
 
179
237
  # Return the URI path for the given +route_definition+
@@ -181,44 +239,196 @@ class Otto
181
239
  #
182
240
  # Otto.default.path 'YourClass.somemethod' #=> /some/path
183
241
  #
184
- def uri route_definition, params={}
185
- #raise RuntimeError, "Not working"
242
+ def uri(route_definition, params = {})
243
+ # raise RuntimeError, "Not working"
186
244
  route = @route_definitions[route_definition]
187
- unless route.nil?
188
- local_params = params.clone
189
- local_path = route.path.clone
190
- if objid = local_params.delete(:id) || local_params.delete('id')
191
- local_path.gsub! /\*/, objid
192
- end
193
- local_params.each_pair { |k,v|
194
- next unless local_path.match(":#{k}")
195
- local_path.gsub!(":#{k}", local_params.delete(k))
196
- }
197
- uri = Addressable::URI.new
198
- uri.path = local_path
199
- uri.query_values = local_params
200
- uri.to_s
245
+ return if route.nil?
246
+
247
+ local_params = params.clone
248
+ local_path = route.path.clone
249
+
250
+ local_params.each_pair do |k, v|
251
+ next unless local_path.match(":#{k}")
252
+
253
+ local_path.gsub!(":#{k}", v.to_s)
254
+ local_params.delete(k)
201
255
  end
256
+ uri = Addressable::URI.new
257
+ uri.path = local_path
258
+ uri.query_values = local_params unless local_params.empty?
259
+ uri.to_s
202
260
  end
203
261
 
204
- def determine_locale env
262
+ def determine_locale(env)
205
263
  accept_langs = env['HTTP_ACCEPT_LANGUAGE']
206
- accept_langs = self.option[:locale] if accept_langs.to_s.empty?
264
+ accept_langs = option[:locale] if accept_langs.to_s.empty?
207
265
  locales = []
208
266
  unless accept_langs.empty?
209
- locales = accept_langs.split(',').map { |l|
210
- l += ';q=1.0' unless l =~ /;q=\d+(?:\.\d+)?$/
267
+ locales = accept_langs.split(',').map do |l|
268
+ l += ';q=1.0' unless /;q=\d+(?:\.\d+)?$/.match?(l)
211
269
  l.split(';q=')
212
- }.sort_by { |locale, qvalue|
270
+ end.sort_by do |_locale, qvalue|
213
271
  qvalue.to_f
214
- }.collect { |locale, qvalue|
272
+ end.collect do |locale, _qvalue|
215
273
  locale
216
- }.reverse
274
+ end.reverse
217
275
  end
218
276
  Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
219
277
  locales.empty? ? nil : locales
220
278
  end
221
279
 
280
+ # Add middleware to the stack
281
+ #
282
+ # @param middleware [Class] The middleware class to add
283
+ # @param args [Array] Additional arguments for the middleware
284
+ # @param block [Proc] Optional block for middleware configuration
285
+ def use(middleware, *args, &block)
286
+ @middleware_stack << middleware
287
+ end
288
+
289
+ # Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
290
+ # This will automatically add CSRF tokens to HTML forms and validate
291
+ # them on unsafe HTTP methods.
292
+ #
293
+ # @example
294
+ # otto.enable_csrf_protection!
295
+ def enable_csrf_protection!
296
+ return if middleware_enabled?(Otto::Security::CSRFMiddleware)
297
+
298
+ @security_config.enable_csrf_protection!
299
+ use Otto::Security::CSRFMiddleware
300
+ end
301
+
302
+ # Enable request validation including input sanitization, size limits,
303
+ # and protection against XSS and SQL injection attacks.
304
+ #
305
+ # @example
306
+ # otto.enable_request_validation!
307
+ def enable_request_validation!
308
+ return if middleware_enabled?(Otto::Security::ValidationMiddleware)
309
+
310
+ @security_config.input_validation = true
311
+ use Otto::Security::ValidationMiddleware
312
+ end
313
+
314
+ # Add a trusted proxy server for accurate client IP detection.
315
+ # Only requests from trusted proxies will have their forwarded headers honored.
316
+ #
317
+ # @param proxy [String, Regexp] IP address, CIDR range, or regex pattern
318
+ # @example
319
+ # otto.add_trusted_proxy('10.0.0.0/8')
320
+ # otto.add_trusted_proxy(/^172\.16\./)
321
+ def add_trusted_proxy(proxy)
322
+ @security_config.add_trusted_proxy(proxy)
323
+ end
324
+
325
+ # Set custom security headers that will be added to all responses.
326
+ # These merge with the default security headers.
327
+ #
328
+ # @param headers [Hash] Hash of header name => value pairs
329
+ # @example
330
+ # otto.set_security_headers({
331
+ # 'content-security-policy' => "default-src 'self'",
332
+ # 'strict-transport-security' => 'max-age=31536000'
333
+ # })
334
+ def set_security_headers(headers)
335
+ @security_config.security_headers.merge!(headers)
336
+ end
337
+
338
+ # Enable HTTP Strict Transport Security (HSTS) header.
339
+ # WARNING: This can make your domain inaccessible if HTTPS is not properly
340
+ # configured. Only enable this when you're certain HTTPS is working correctly.
341
+ #
342
+ # @param max_age [Integer] Maximum age in seconds (default: 1 year)
343
+ # @param include_subdomains [Boolean] Apply to all subdomains (default: true)
344
+ # @example
345
+ # otto.enable_hsts!(max_age: 86400, include_subdomains: false)
346
+ def enable_hsts!(max_age: 31536000, include_subdomains: true)
347
+ @security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
348
+ end
349
+
350
+ # Enable Content Security Policy (CSP) header to prevent XSS attacks.
351
+ # The default policy only allows resources from the same origin.
352
+ #
353
+ # @param policy [String] CSP policy string (default: "default-src 'self'")
354
+ # @example
355
+ # otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
356
+ def enable_csp!(policy = "default-src 'self'")
357
+ @security_config.enable_csp!(policy)
358
+ end
359
+
360
+ # Enable X-Frame-Options header to prevent clickjacking attacks.
361
+ #
362
+ # @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
363
+ # @example
364
+ # otto.enable_frame_protection!('DENY')
365
+ def enable_frame_protection!(option = 'SAMEORIGIN')
366
+ @security_config.enable_frame_protection!(option)
367
+ end
368
+
369
+ private
370
+
371
+ def configure_security(opts)
372
+ # Enable CSRF protection if requested
373
+ enable_csrf_protection! if opts[:csrf_protection]
374
+
375
+ # Enable request validation if requested
376
+ enable_request_validation! if opts[:request_validation]
377
+
378
+ # Add trusted proxies if provided
379
+ if opts[:trusted_proxies]
380
+ Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
381
+ end
382
+
383
+ # Set custom security headers
384
+ if opts[:security_headers]
385
+ set_security_headers(opts[:security_headers])
386
+ end
387
+ end
388
+
389
+ def middleware_enabled?(middleware_class)
390
+ @middleware_stack.any? { |m| m == middleware_class }
391
+ end
392
+
393
+ def handle_error(error, env)
394
+ # Log error details internally but don't expose them
395
+ error_id = SecureRandom.hex(8)
396
+ Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
397
+ Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
398
+
399
+ # Check for custom error routes
400
+ request = Rack::Request.new(env) rescue nil
401
+ literal_routes = @routes_literal[:GET] || {}
402
+
403
+ # Try custom 500 route first
404
+ if found_route = literal_routes['/500']
405
+ begin
406
+ env['otto.error_id'] = error_id
407
+ return found_route.call(env)
408
+ rescue StandardError => route_error
409
+ Otto.logger.error "[#{error_id}] Error in custom error handler: #{route_error.message}"
410
+ end
411
+ end
412
+
413
+ # Fallback to built-in error response
414
+ @server_error || secure_error_response(error_id)
415
+ end
416
+
417
+ def secure_error_response(error_id)
418
+ body = if Otto.env?(:dev, :development)
419
+ "Server error (ID: #{error_id}). Check logs for details."
420
+ else
421
+ "An error occurred. Please try again later."
422
+ end
423
+
424
+ headers = {
425
+ 'content-type' => 'text/plain',
426
+ 'content-length' => body.bytesize.to_s
427
+ }.merge(@security_config.security_headers)
428
+
429
+ [500, headers, [body]]
430
+ end
431
+
222
432
  class << self
223
433
  attr_accessor :debug, :logger
224
434
  end
@@ -228,15 +438,19 @@ class Otto
228
438
  @default ||= Otto.new
229
439
  @default
230
440
  end
231
- def load path
441
+
442
+ def load(path)
232
443
  default.load path
233
444
  end
234
- def path definition, params={}
445
+
446
+ def path(definition, params = {})
235
447
  default.path definition, params
236
448
  end
449
+
237
450
  def routes
238
451
  default.routes
239
452
  end
453
+
240
454
  def env? *guesses
241
455
  !guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
242
456
  end
data/otto.gemspec CHANGED
@@ -1,27 +1,28 @@
1
- # -*- encoding: utf-8 -*-
1
+ # otto.gemspec
2
2
 
3
3
  require_relative 'lib/otto/version'
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = "otto"
6
+ spec.name = 'otto'
7
7
  spec.version = Otto::VERSION.to_s
8
- spec.summary = "Auto-define your rack-apps in plaintext."
8
+ spec.summary = 'Auto-define your rack-apps in plaintext.'
9
9
  spec.description = "Otto: #{spec.summary}"
10
- spec.email = "gems@solutious.com"
11
- spec.authors = ["Delano Mandelbaum"]
12
- spec.license = "MIT"
10
+ spec.email = 'gems@solutious.com'
11
+ spec.authors = ['Delano Mandelbaum']
12
+ spec.license = 'MIT'
13
13
  spec.files = Dir.chdir(File.expand_path(__dir__)) do
14
14
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
15
15
  end
16
- spec.homepage = "https://github.com/delano/otto"
17
- spec.require_paths = ["lib"]
18
- spec.rubygems_version = "3.5.15" # Update to the latest version
16
+ spec.homepage = 'https://github.com/delano/otto'
17
+ spec.require_paths = ['lib']
19
18
 
20
- spec.required_ruby_version = ['>= 2.6.8', '< 4.0']
19
+ spec.required_ruby_version = ['>= 3.4', '< 4.0']
21
20
 
22
21
  # https://github.com/delano/otto/security/dependabot/5
23
22
  spec.add_dependency 'rexml', '>= 3.3.6'
24
23
 
25
- spec.add_runtime_dependency 'addressable', '~> 2.2', '< 3'
26
- spec.add_runtime_dependency 'rack', '~> 2.2', '< 3.0'
24
+ spec.add_dependency 'addressable', '~> 2.2', '< 3'
25
+ spec.add_dependency 'rack', '~> 3.1', '< 4.0'
26
+ spec.add_dependency 'rack-parser', '~> 0.7'
27
+ spec.metadata['rubygems_mfa_required'] = 'true'
27
28
  end