otto 1.1.0.pre.alpha3 → 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,79 +42,141 @@ 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
- path_info_clean = path_info.gsub /\/$/, ''
162
+
163
+ begin
164
+ path_info_clean = path_info
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
176
+ # Set a default value or use the original path_info
177
+ path_info_clean = path_info
178
+ end
179
+
96
180
  base_path = File.split(path_info).first
97
181
  # Files in the root directory can refer to themselves
98
182
  base_path = path_info if base_path == '/'
@@ -100,49 +184,47 @@ class Otto
100
184
  literal_routes = routes_literal[http_verb] || {}
101
185
  literal_routes.merge! routes_literal[:GET] if http_verb == :HEAD
102
186
  if static_route && http_verb == :GET && routes_static[:GET].member?(base_path)
103
- #Otto.logger.debug " request: #{path_info} (static)"
187
+ # Otto.logger.debug " request: #{path_info} (static)"
104
188
  static_route.call(env)
105
189
  elsif literal_routes.has_key?(path_info_clean)
106
190
  route = literal_routes[path_info_clean]
107
- #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})"
108
192
  route.call(env)
109
193
  elsif static_route && http_verb == :GET && safe_file?(path_info)
110
- static_path = File.join(option[:public], base_path)
111
- Otto.logger.debug " new static route: #{base_path} (#{path_info})"
194
+ Otto.logger.debug " new static route: #{base_path} (#{path_info})" if Otto.debug
112
195
  routes_static[:GET][base_path] = base_path
113
196
  static_route.call(env)
114
197
  else
115
198
  extra_params = {}
116
199
  found_route = nil
117
200
  valid_routes = routes[http_verb] || []
118
- valid_routes.push *routes[:GET] if http_verb == :HEAD
119
- valid_routes.each { |route|
120
- #Otto.logger.debug " request: #{http_verb} #{path_info} (trying route: #{route.verb} #{route.pattern})"
121
- if (match = route.pattern.match(path_info))
122
- values = match.captures.to_a
123
- # The first capture returned is the entire matched string b/c
124
- # we wrapped the entire regex in parens. We don't need it to
125
- # the full match.
126
- full_match = values.shift
127
- extra_params =
128
- if route.keys.any?
129
- route.keys.zip(values).inject({}) do |hash,(k,v)|
130
- if k == 'splat'
131
- (hash[k] ||= []) << v
132
- else
133
- hash[k] = v
134
- end
135
- 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
136
218
  end
137
- elsif values.any?
138
- {'captures' => values}
139
- else
140
- {}
141
219
  end
142
- found_route = route
143
- break
144
- end
145
- }
220
+ elsif values.any?
221
+ { 'captures' => values }
222
+ else
223
+ {}
224
+ end
225
+ found_route = route
226
+ break
227
+ end
146
228
  found_route ||= literal_routes['/404']
147
229
  if found_route
148
230
  found_route.call env, extra_params
@@ -150,14 +232,6 @@ class Otto
150
232
  @not_found || Otto::Static.not_found
151
233
  end
152
234
  end
153
- rescue => ex
154
- Otto.logger.error "#{ex.class}: #{ex.message} #{ex.backtrace.join("\n")}"
155
-
156
- if found_route = literal_routes['/500']
157
- found_route.call env
158
- else
159
- @server_error || Otto::Static.server_error
160
- end
161
235
  end
162
236
 
163
237
  # Return the URI path for the given +route_definition+
@@ -165,44 +239,196 @@ class Otto
165
239
  #
166
240
  # Otto.default.path 'YourClass.somemethod' #=> /some/path
167
241
  #
168
- def uri route_definition, params={}
169
- #raise RuntimeError, "Not working"
242
+ def uri(route_definition, params = {})
243
+ # raise RuntimeError, "Not working"
170
244
  route = @route_definitions[route_definition]
171
- unless route.nil?
172
- local_params = params.clone
173
- local_path = route.path.clone
174
- if objid = local_params.delete(:id) || local_params.delete('id')
175
- local_path.gsub! /\*/, objid
176
- end
177
- local_params.each_pair { |k,v|
178
- next unless local_path.match(":#{k}")
179
- local_path.gsub!(":#{k}", local_params.delete(k))
180
- }
181
- uri = Addressable::URI.new
182
- uri.path = local_path
183
- uri.query_values = local_params
184
- 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)
185
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
186
260
  end
187
261
 
188
- def determine_locale env
262
+ def determine_locale(env)
189
263
  accept_langs = env['HTTP_ACCEPT_LANGUAGE']
190
- accept_langs = self.option[:locale] if accept_langs.to_s.empty?
264
+ accept_langs = option[:locale] if accept_langs.to_s.empty?
191
265
  locales = []
192
266
  unless accept_langs.empty?
193
- locales = accept_langs.split(',').map { |l|
194
- 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)
195
269
  l.split(';q=')
196
- }.sort_by { |locale, qvalue|
270
+ end.sort_by do |_locale, qvalue|
197
271
  qvalue.to_f
198
- }.collect { |locale, qvalue|
272
+ end.collect do |locale, _qvalue|
199
273
  locale
200
- }.reverse
274
+ end.reverse
201
275
  end
202
276
  Otto.logger.debug "locale: #{locales} (#{accept_langs})" if Otto.debug
203
277
  locales.empty? ? nil : locales
204
278
  end
205
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
+
206
432
  class << self
207
433
  attr_accessor :debug, :logger
208
434
  end
@@ -212,15 +438,19 @@ class Otto
212
438
  @default ||= Otto.new
213
439
  @default
214
440
  end
215
- def load path
441
+
442
+ def load(path)
216
443
  default.load path
217
444
  end
218
- def path definition, params={}
445
+
446
+ def path(definition, params = {})
219
447
  default.path definition, params
220
448
  end
449
+
221
450
  def routes
222
451
  default.routes
223
452
  end
453
+
224
454
  def env? *guesses
225
455
  !guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
226
456
  end
data/otto.gemspec CHANGED
@@ -1,24 +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
- spec.add_runtime_dependency 'addressable', '~> 2.2', '< 3'
23
- spec.add_runtime_dependency 'rack', '~> 2.2', '< 3.0'
21
+ # https://github.com/delano/otto/security/dependabot/5
22
+ spec.add_dependency 'rexml', '>= 3.3.6'
23
+
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'
24
28
  end