otto 1.4.0 → 1.6.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.
@@ -25,7 +25,8 @@ class Otto
25
25
  :max_request_size, :max_param_depth, :max_param_keys,
26
26
  :trusted_proxies, :require_secure_cookies,
27
27
  :security_headers, :input_validation,
28
- :csp_nonce_enabled, :debug_csp
28
+ :csp_nonce_enabled, :debug_csp, :mcp_auth,
29
+ :rate_limiting_config
29
30
 
30
31
  # Initialize security configuration with safe defaults
31
32
  #
@@ -45,6 +46,7 @@ class Otto
45
46
  @input_validation = true
46
47
  @csp_nonce_enabled = false
47
48
  @debug_csp = false
49
+ @rate_limiting_config = {}
48
50
  end
49
51
 
50
52
  # Enable CSRF (Cross-Site Request Forgery) protection
@@ -0,0 +1,111 @@
1
+ require 'json'
2
+
3
+ begin
4
+ require 'rack/attack'
5
+ rescue LoadError
6
+ # rack-attack is optional - graceful fallback
7
+ end
8
+
9
+ class Otto
10
+ module Security
11
+ class RateLimiting
12
+ def self.configure_rack_attack!(config = {})
13
+ return unless defined?(Rack::Attack)
14
+
15
+ # Use provided cache store or default
16
+ if config[:cache_store]
17
+ Rack::Attack.cache.store = config[:cache_store]
18
+ end
19
+
20
+ # Default rules
21
+ default_requests_per_minute = config.fetch(:requests_per_minute, 100)
22
+
23
+ # General request throttling
24
+ Rack::Attack.throttle('requests', limit: default_requests_per_minute, period: 60) do |request|
25
+ request.ip unless request.path.start_with?('/_') # Skip internal paths by default
26
+ end
27
+
28
+ # Apply custom rules if provided
29
+ if config[:custom_rules]
30
+ config[:custom_rules].each do |name, rule_config|
31
+ limit = rule_config[:limit]
32
+ period = rule_config[:period] || 60
33
+ condition = rule_config[:condition]
34
+
35
+ Rack::Attack.throttle(name.to_s, limit: limit, period: period) do |request|
36
+ if condition
37
+ request.ip if condition.call(request)
38
+ else
39
+ request.ip
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ # Custom response for rate limited requests
46
+ Rack::Attack.throttled_responder = lambda do |request|
47
+ match_data = request.env['rack.attack.match_data']
48
+ now = match_data[:epoch_time]
49
+
50
+ headers = {
51
+ 'content-type' => 'application/json',
52
+ 'retry-after' => (match_data[:period] - (now % match_data[:period])).to_s,
53
+ }
54
+
55
+ # Check if request expects JSON
56
+ accept_header = request.env['HTTP_ACCEPT'].to_s
57
+ if accept_header.include?('application/json')
58
+ error_response = {
59
+ error: 'Rate limit exceeded',
60
+ message: 'Too many requests',
61
+ retry_after: headers['retry-after'].to_i,
62
+ limit: match_data[:limit],
63
+ period: match_data[:period],
64
+ }
65
+ [429, headers, [JSON.generate(error_response)]]
66
+ else
67
+ body = "Rate limit exceeded. Retry after #{headers['retry-after']} seconds."
68
+ headers['content-type'] = 'text/plain'
69
+ [429, headers, [body]]
70
+ end
71
+ end
72
+
73
+ # Log blocked requests if ActiveSupport is available
74
+ return unless defined?(ActiveSupport::Notifications)
75
+
76
+ ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, payload|
77
+ req = payload[:request]
78
+ Otto.logger.warn "[Otto] Rate limit #{payload[:match_type]} for #{req.ip}: #{payload[:matched]}"
79
+ end
80
+ end
81
+ end
82
+
83
+ class RateLimitMiddleware
84
+ def initialize(app, security_config = nil)
85
+ @app = app
86
+ @security_config = security_config
87
+ @rate_limiter_available = defined?(Rack::Attack)
88
+
89
+ if @rate_limiter_available
90
+ configure_rate_limiting
91
+ else
92
+ Otto.logger.warn '[Otto] rack-attack not available - rate limiting disabled'
93
+ end
94
+ end
95
+
96
+ def call(env)
97
+ return @app.call(env) unless @rate_limiter_available
98
+
99
+ # Let rack-attack handle the rate limiting
100
+ @app.call(env)
101
+ end
102
+
103
+ private
104
+
105
+ def configure_rate_limiting
106
+ config = @security_config&.rate_limiting_config || {}
107
+ RateLimiting.configure_rack_attack!(config)
108
+ end
109
+ end
110
+ end
111
+ end
@@ -2,25 +2,21 @@
2
2
 
3
3
  require 'json'
4
4
  require 'cgi'
5
+ require 'loofah'
6
+ require 'facets/file'
7
+
8
+ require_relative '../helpers/validation'
5
9
 
6
10
  class Otto
7
11
  module Security
8
12
  # ValidationMiddleware provides input validation and sanitization for web requests
13
+ # Uses Loofah for HTML/XSS sanitization and Facets for filename sanitization
9
14
  class ValidationMiddleware
10
15
  # Character validation patterns
11
16
  INVALID_CHARACTERS = /[\x00-\x1f\x7f-\xff]/n
12
17
  NULL_BYTE = /\0/
13
18
 
14
- DANGEROUS_PATTERNS = [
15
- /<script[^>]*>/i, # Script tags
16
- /javascript:/i, # JavaScript protocol
17
- /data:.*base64/i, # Data URLs with base64
18
- /on\w+\s*=/i, # Event handlers
19
- /expression\s*\(/i, # CSS expressions
20
- /url\s*\(/i, # CSS url() functions
21
- NULL_BYTE, # Null bytes
22
- INVALID_CHARACTERS, # Control characters and extended ASCII
23
- ].freeze
19
+ # HTML/XSS sanitization is handled by Loofah library for better security coverage
24
20
 
25
21
  SQL_INJECTION_PATTERNS = [
26
22
  /('|(\\')|(;)|(\\)|(--)|(%27)|(%3B)|(%3D))/i,
@@ -95,7 +91,7 @@ class Otto
95
91
  end
96
92
 
97
93
  def validate_param_structure(params, depth = 0)
98
- if depth > @config.max_param_depth
94
+ if depth >= @config.max_param_depth
99
95
  raise Otto::Security::ValidationError, "Parameter depth exceeds maximum (#{@config.max_param_depth})"
100
96
  end
101
97
 
@@ -150,32 +146,44 @@ class Otto
150
146
  def sanitize_value(value)
151
147
  return value unless value.is_a?(String)
152
148
 
153
- # Check for dangerous patterns
154
- DANGEROUS_PATTERNS.each do |pattern|
155
- if value.match?(pattern)
156
- raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
157
- end
149
+ # Check for extremely long values first
150
+ if value.length > 10_000
151
+ raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
152
+ end
153
+
154
+ # Start with the original value
155
+ original = value.dup
156
+
157
+ # Check for null bytes first (these should be rejected, not sanitized)
158
+ if original.match?(NULL_BYTE)
159
+ raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
160
+ end
161
+
162
+ # Check for script injection first (these should always be rejected)
163
+ if looks_like_script_injection?(original)
164
+ raise Otto::Security::ValidationError, 'Dangerous content detected in parameter'
158
165
  end
159
166
 
167
+ # Use Loofah to sanitize HTML/XSS content for less dangerous HTML
168
+ # Loofah.fragment removes dangerous HTML but preserves safe content
169
+ sanitized = Loofah.fragment(original).scrub!(:whitewash).to_s
170
+
171
+ # Remove control characters (sanitize, don't block)
172
+ sanitized = sanitized.gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
173
+
160
174
  # Check for SQL injection patterns
161
175
  SQL_INJECTION_PATTERNS.each do |pattern|
162
- if value.match?(pattern)
176
+ if sanitized.match?(pattern)
163
177
  raise Otto::Security::ValidationError, 'Potential SQL injection detected'
164
178
  end
165
179
  end
166
180
 
167
- # Check for extremely long values
168
- if value.length > 10_000
169
- raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
170
- end
181
+ sanitized
182
+ end
171
183
 
172
- # Basic sanitization - remove null bytes and control characters
173
- sanitized = value.gsub(/\0/, '').gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
184
+ include ValidationHelpers
174
185
 
175
- # Additional sanitization for common attack vectors
176
- sanitized = sanitized.gsub(/<!--.*?-->/m, '') # Remove HTML comments
177
- sanitized.gsub(/<!\[CDATA\[.*?\]\]>/m, '') # Remove CDATA sections
178
- end
186
+ private
179
187
 
180
188
  def validate_headers(request)
181
189
  # Check for dangerous headers
@@ -248,52 +256,5 @@ class Otto
248
256
  }.to_json
249
257
  end
250
258
  end
251
-
252
- module ValidationHelpers
253
- def validate_input(input, max_length: 1000, allow_html: false)
254
- return input if input.nil? || input.empty?
255
-
256
- input_str = input.to_s
257
-
258
- # Check length
259
- if input_str.length > max_length
260
- raise Otto::Security::ValidationError, "Input too long (#{input_str.length} > #{max_length})"
261
- end
262
-
263
- # Check for dangerous patterns unless HTML is allowed
264
- unless allow_html
265
- ValidationMiddleware::DANGEROUS_PATTERNS.each do |pattern|
266
- if input_str.match?(pattern)
267
- raise Otto::Security::ValidationError, 'Dangerous content detected'
268
- end
269
- end
270
- end
271
-
272
- # Always check for SQL injection
273
- ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
274
- if input_str.match?(pattern)
275
- raise Otto::Security::ValidationError, 'Potential SQL injection detected'
276
- end
277
- end
278
-
279
- input_str
280
- end
281
-
282
- def sanitize_filename(filename)
283
- return nil if filename.nil? || filename.empty?
284
-
285
- # Remove path components and dangerous characters
286
- clean_name = File.basename(filename.to_s)
287
- clean_name = clean_name.gsub(/[^\w\-_\.]/, '_')
288
- clean_name = clean_name.gsub(/_{2,}/, '_')
289
- clean_name = clean_name.gsub(/^_+|_+$/, '')
290
-
291
- # Ensure it's not empty and has reasonable length
292
- clean_name = 'file' if clean_name.empty?
293
- clean_name = clean_name[0..100] if clean_name.length > 100
294
-
295
- clean_name
296
- end
297
- end
298
259
  end
299
260
  end
data/lib/otto/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # lib/otto/version.rb
2
2
 
3
3
  class Otto
4
- VERSION = '1.4.0'.freeze
4
+ VERSION = '1.6.0'.freeze
5
5
  end
data/lib/otto.rb CHANGED
@@ -8,14 +8,20 @@ require 'rack/request'
8
8
  require 'rack/response'
9
9
  require 'rack/utils'
10
10
 
11
+ require_relative 'otto/route_definition'
11
12
  require_relative 'otto/route'
12
13
  require_relative 'otto/static'
13
14
  require_relative 'otto/helpers/request'
14
15
  require_relative 'otto/helpers/response'
16
+ require_relative 'otto/response_handlers'
17
+ require_relative 'otto/route_handlers'
15
18
  require_relative 'otto/version'
16
19
  require_relative 'otto/security/config'
17
20
  require_relative 'otto/security/csrf'
18
21
  require_relative 'otto/security/validator'
22
+ require_relative 'otto/security/authentication'
23
+ require_relative 'otto/security/rate_limiting'
24
+ require_relative 'otto/mcp/server'
19
25
 
20
26
  # Otto is a simple Rack router that allows you to define routes in a file
21
27
  # with built-in security features including CSRF protection, input validation,
@@ -56,7 +62,7 @@ class Otto
56
62
  @global_config
57
63
  end
58
64
 
59
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config
65
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config, :auth_config, :route_handler_factory, :mcp_server
60
66
  attr_accessor :not_found, :server_error, :middleware_stack
61
67
 
62
68
  def initialize(path = nil, opts = {})
@@ -70,6 +76,7 @@ class Otto
70
76
  }.merge(opts)
71
77
  @security_config = Otto::Security::Config.new
72
78
  @middleware_stack = []
79
+ @route_handler_factory = opts[:route_handler_factory] || Otto::RouteHandlers::HandlerFactory
73
80
 
74
81
  # Configure locale support (merge global config with instance options)
75
82
  configure_locale(opts)
@@ -77,6 +84,12 @@ class Otto
77
84
  # Configure security based on options
78
85
  configure_security(opts)
79
86
 
87
+ # Configure authentication based on options
88
+ configure_authentication(opts)
89
+
90
+ # Initialize MCP server
91
+ configure_mcp(opts)
92
+
80
93
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
81
94
  load(path) unless path.nil?
82
95
  super()
@@ -87,9 +100,22 @@ class Otto
87
100
  path = File.expand_path(path)
88
101
  raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
89
102
 
90
- raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip.split(/\s+/) }
103
+ raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
91
104
  raw.each do |entry|
92
- verb, path, definition = *entry
105
+ # Enhanced parsing: split only on first two whitespace boundaries
106
+ # This preserves parameters in the definition part
107
+ parts = entry.split(/\s+/, 3)
108
+ verb, path, definition = parts[0], parts[1], parts[2]
109
+
110
+ # Check for MCP routes
111
+ if Otto::MCP::RouteParser.is_mcp_route?(definition)
112
+ handle_mcp_route(verb, path, definition) if @mcp_server
113
+ next
114
+ elsif Otto::MCP::RouteParser.is_tool_route?(definition)
115
+ handle_tool_route(verb, path, definition) if @mcp_server
116
+ next
117
+ end
118
+
93
119
  route = Otto::Route.new verb, path, definition
94
120
  route.otto = self
95
121
  path_clean = path.gsub(%r{/$}, '')
@@ -332,6 +358,52 @@ class Otto
332
358
  use Otto::Security::ValidationMiddleware
333
359
  end
334
360
 
361
+ # Enable rate limiting to protect against abuse and DDoS attacks.
362
+ # This will automatically add rate limiting rules based on client IP.
363
+ #
364
+ # @param options [Hash] Rate limiting configuration options
365
+ # @option options [Integer] :requests_per_minute Maximum requests per minute per IP (default: 100)
366
+ # @option options [Hash] :custom_rules Custom rate limiting rules
367
+ # @example
368
+ # otto.enable_rate_limiting!(requests_per_minute: 50)
369
+ def enable_rate_limiting!(options = {})
370
+ return if middleware_enabled?(Otto::Security::RateLimitMiddleware)
371
+
372
+ configure_rate_limiting(options)
373
+ use Otto::Security::RateLimitMiddleware
374
+ end
375
+
376
+ # Configure rate limiting settings.
377
+ #
378
+ # @param config [Hash] Rate limiting configuration
379
+ # @option config [Integer] :requests_per_minute Maximum requests per minute per IP
380
+ # @option config [Hash] :custom_rules Hash of custom rate limiting rules
381
+ # @option config [Object] :cache_store Custom cache store for rate limiting
382
+ # @example
383
+ # otto.configure_rate_limiting({
384
+ # requests_per_minute: 50,
385
+ # custom_rules: {
386
+ # 'api_calls' => { limit: 30, period: 60, condition: ->(req) { req.path.start_with?('/api') }}
387
+ # }
388
+ # })
389
+ def configure_rate_limiting(config)
390
+ @security_config.rate_limiting_config.merge!(config)
391
+ end
392
+
393
+ # Add a custom rate limiting rule.
394
+ #
395
+ # @param name [String, Symbol] Rule name
396
+ # @param options [Hash] Rule configuration
397
+ # @option options [Integer] :limit Maximum requests
398
+ # @option options [Integer] :period Time period in seconds (default: 60)
399
+ # @option options [Proc] :condition Optional condition proc that receives request
400
+ # @example
401
+ # otto.add_rate_limit_rule('uploads', limit: 5, period: 300, condition: ->(req) { req.post? && req.path.include?('upload') })
402
+ def add_rate_limit_rule(name, options)
403
+ @security_config.rate_limiting_config[:custom_rules] ||= {}
404
+ @security_config.rate_limiting_config[:custom_rules][name.to_s] = options
405
+ end
406
+
335
407
  # Add a trusted proxy server for accurate client IP detection.
336
408
  # Only requests from trusted proxies will have their forwarded headers honored.
337
409
  #
@@ -412,6 +484,72 @@ class Otto
412
484
  @locale_config[:default_locale] = default_locale if default_locale
413
485
  end
414
486
 
487
+ # Enable authentication middleware for route-level access control.
488
+ # This will automatically check route auth parameters and enforce authentication.
489
+ #
490
+ # @example
491
+ # otto.enable_authentication!
492
+ def enable_authentication!
493
+ return if middleware_enabled?(Otto::Security::AuthenticationMiddleware)
494
+
495
+ use Otto::Security::AuthenticationMiddleware, @auth_config
496
+ end
497
+
498
+ # Configure authentication strategies for route-level access control.
499
+ #
500
+ # @param strategies [Hash] Hash mapping strategy names to strategy instances
501
+ # @param default_strategy [String] Default strategy to use when none specified
502
+ # @example
503
+ # otto.configure_auth_strategies({
504
+ # 'publically' => Otto::Security::PublicStrategy.new,
505
+ # 'authenticated' => Otto::Security::SessionStrategy.new(session_key: 'user_id'),
506
+ # 'role:admin' => Otto::Security::RoleStrategy.new(['admin']),
507
+ # 'api_key' => Otto::Security::APIKeyStrategy.new(api_keys: ['secret123'])
508
+ # })
509
+ def configure_auth_strategies(strategies, default_strategy: 'publically')
510
+ @auth_config ||= {}
511
+ @auth_config[:auth_strategies] = strategies
512
+ @auth_config[:default_auth_strategy] = default_strategy
513
+
514
+ enable_authentication! unless strategies.empty?
515
+ end
516
+
517
+ # Add a single authentication strategy
518
+ #
519
+ # @param name [String] Strategy name
520
+ # @param strategy [Otto::Security::AuthStrategy] Strategy instance
521
+ # @example
522
+ # otto.add_auth_strategy('custom', MyCustomStrategy.new)
523
+ def add_auth_strategy(name, strategy)
524
+ @auth_config ||= { auth_strategies: {}, default_auth_strategy: 'publically' }
525
+ @auth_config[:auth_strategies][name] = strategy
526
+
527
+ enable_authentication!
528
+ end
529
+
530
+ # Enable MCP (Model Context Protocol) server support
531
+ #
532
+ # @param options [Hash] MCP configuration options
533
+ # @option options [Boolean] :http Enable HTTP endpoint (default: true)
534
+ # @option options [Boolean] :stdio Enable STDIO communication (default: false)
535
+ # @option options [String] :endpoint HTTP endpoint path (default: '/_mcp')
536
+ # @example
537
+ # otto.enable_mcp!(http: true, endpoint: '/api/mcp')
538
+ def enable_mcp!(options = {})
539
+ unless @mcp_server
540
+ @mcp_server = Otto::MCP::Server.new(self)
541
+ end
542
+
543
+ @mcp_server.enable!(options)
544
+ Otto.logger.info "[MCP] Enabled MCP server" if Otto.debug
545
+ end
546
+
547
+ # Check if MCP is enabled
548
+ # @return [Boolean]
549
+ def mcp_enabled?
550
+ @mcp_server&.enabled?
551
+ end
552
+
415
553
  private
416
554
 
417
555
  def configure_locale(opts)
@@ -452,6 +590,12 @@ class Otto
452
590
  # Enable request validation if requested
453
591
  enable_request_validation! if opts[:request_validation]
454
592
 
593
+ # Enable rate limiting if requested
594
+ if opts[:rate_limiting]
595
+ rate_limiting_opts = opts[:rate_limiting].is_a?(Hash) ? opts[:rate_limiting] : {}
596
+ enable_rate_limiting!(rate_limiting_opts)
597
+ end
598
+
455
599
  # Add trusted proxies if provided
456
600
  if opts[:trusted_proxies]
457
601
  Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
@@ -467,6 +611,55 @@ class Otto
467
611
  @middleware_stack.any? { |m| m == middleware_class }
468
612
  end
469
613
 
614
+ def configure_authentication(opts)
615
+ # Configure authentication strategies
616
+ @auth_config = {
617
+ auth_strategies: opts[:auth_strategies] || {},
618
+ default_auth_strategy: opts[:default_auth_strategy] || 'publically'
619
+ }
620
+
621
+ # Enable authentication middleware if strategies are configured
622
+ if opts[:auth_strategies] && !opts[:auth_strategies].empty?
623
+ enable_authentication!
624
+ end
625
+ end
626
+
627
+ def configure_mcp(opts)
628
+ @mcp_server = nil
629
+
630
+ # Enable MCP if requested in options
631
+ if opts[:mcp_enabled] || opts[:mcp_http] || opts[:mcp_stdio]
632
+ @mcp_server = Otto::MCP::Server.new(self)
633
+
634
+ mcp_options = {}
635
+ mcp_options[:http_endpoint] = opts[:mcp_endpoint] if opts[:mcp_endpoint]
636
+
637
+ if opts[:mcp_http] != false # Default to true unless explicitly disabled
638
+ @mcp_server.enable!(mcp_options)
639
+ end
640
+ end
641
+ end
642
+
643
+ def handle_mcp_route(verb, path, definition)
644
+ begin
645
+ route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
646
+ @mcp_server.register_mcp_route(route_info)
647
+ Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
648
+ rescue => e
649
+ Otto.logger.error "[MCP] Failed to parse MCP route: #{definition} - #{e.message}"
650
+ end
651
+ end
652
+
653
+ def handle_tool_route(verb, path, definition)
654
+ begin
655
+ route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
656
+ @mcp_server.register_mcp_route(route_info)
657
+ Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
658
+ rescue => e
659
+ Otto.logger.error "[MCP] Failed to parse TOOL route: #{definition} - #{e.message}"
660
+ end
661
+ end
662
+
470
663
  def handle_error(error, env)
471
664
  # Log error details internally but don't expose them
472
665
  error_id = SecureRandom.hex(8)
data/otto.gemspec CHANGED
@@ -10,18 +10,23 @@ Gem::Specification.new do |spec|
10
10
  spec.email = 'gems@solutious.com'
11
11
  spec.authors = ['Delano Mandelbaum']
12
12
  spec.license = 'MIT'
13
- spec.files = Dir.chdir(File.expand_path(__dir__)) do
14
- `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
15
- end
13
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
16
14
  spec.homepage = 'https://github.com/delano/otto'
17
15
  spec.require_paths = ['lib']
18
16
 
19
17
  spec.required_ruby_version = ['>= 3.2', '< 4.0']
20
18
 
21
- # https://github.com/delano/otto/security/dependabot/5
22
- spec.add_dependency 'rexml', '>= 3.3.6'
23
- spec.add_dependency 'ostruct'
19
+ spec.add_dependency 'ostruct', '~> 0.6.3'
24
20
  spec.add_dependency 'rack', '~> 3.1', '< 4.0'
25
21
  spec.add_dependency 'rack-parser', '~> 0.7'
22
+ spec.add_dependency 'rexml', '~> 3.3', '>= 3.3.6'
23
+
24
+ # Security dependencies
25
+ spec.add_dependency 'facets', '~> 3.1'
26
+ spec.add_dependency 'loofah', '~> 2.20'
27
+
28
+ # Optional MCP dependencies
29
+ # spec.add_dependency 'json_schemer', '~> 2.0'
30
+ # spec.add_dependency 'rack-attack', '~> 6.7'
26
31
  spec.metadata['rubygems_mfa_required'] = 'true'
27
32
  end