otto 1.5.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.
@@ -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.5.0'.freeze
4
+ VERSION = '1.6.0'.freeze
5
5
  end
data/lib/otto.rb CHANGED
@@ -20,6 +20,8 @@ require_relative 'otto/security/config'
20
20
  require_relative 'otto/security/csrf'
21
21
  require_relative 'otto/security/validator'
22
22
  require_relative 'otto/security/authentication'
23
+ require_relative 'otto/security/rate_limiting'
24
+ require_relative 'otto/mcp/server'
23
25
 
24
26
  # Otto is a simple Rack router that allows you to define routes in a file
25
27
  # with built-in security features including CSRF protection, input validation,
@@ -60,7 +62,7 @@ class Otto
60
62
  @global_config
61
63
  end
62
64
 
63
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config, :auth_config, :route_handler_factory
65
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route, :security_config, :locale_config, :auth_config, :route_handler_factory, :mcp_server
64
66
  attr_accessor :not_found, :server_error, :middleware_stack
65
67
 
66
68
  def initialize(path = nil, opts = {})
@@ -85,6 +87,9 @@ class Otto
85
87
  # Configure authentication based on options
86
88
  configure_authentication(opts)
87
89
 
90
+ # Initialize MCP server
91
+ configure_mcp(opts)
92
+
88
93
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
89
94
  load(path) unless path.nil?
90
95
  super()
@@ -101,6 +106,16 @@ class Otto
101
106
  # This preserves parameters in the definition part
102
107
  parts = entry.split(/\s+/, 3)
103
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
+
104
119
  route = Otto::Route.new verb, path, definition
105
120
  route.otto = self
106
121
  path_clean = path.gsub(%r{/$}, '')
@@ -343,6 +358,52 @@ class Otto
343
358
  use Otto::Security::ValidationMiddleware
344
359
  end
345
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
+
346
407
  # Add a trusted proxy server for accurate client IP detection.
347
408
  # Only requests from trusted proxies will have their forwarded headers honored.
348
409
  #
@@ -466,6 +527,29 @@ class Otto
466
527
  enable_authentication!
467
528
  end
468
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
+
469
553
  private
470
554
 
471
555
  def configure_locale(opts)
@@ -506,6 +590,12 @@ class Otto
506
590
  # Enable request validation if requested
507
591
  enable_request_validation! if opts[:request_validation]
508
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
+
509
599
  # Add trusted proxies if provided
510
600
  if opts[:trusted_proxies]
511
601
  Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) }
@@ -534,6 +624,42 @@ class Otto
534
624
  end
535
625
  end
536
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
+
537
663
  def handle_error(error, env)
538
664
  # Log error details internally but don't expose them
539
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: otto
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Delano Mandelbaum
@@ -9,34 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: rexml
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: 3.3.6
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: 3.3.6
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: ostruct
28
14
  requirement: !ruby/object:Gem::Requirement
29
15
  requirements:
30
- - - ">="
16
+ - - "~>"
31
17
  - !ruby/object:Gem::Version
32
- version: '0'
18
+ version: 0.6.3
33
19
  type: :runtime
34
20
  prerelease: false
35
21
  version_requirements: !ruby/object:Gem::Requirement
36
22
  requirements:
37
- - - ">="
23
+ - - "~>"
38
24
  - !ruby/object:Gem::Version
39
- version: '0'
25
+ version: 0.6.3
40
26
  - !ruby/object:Gem::Dependency
41
27
  name: rack
42
28
  requirement: !ruby/object:Gem::Requirement
@@ -71,6 +57,54 @@ dependencies:
71
57
  - - "~>"
72
58
  - !ruby/object:Gem::Version
73
59
  version: '0.7'
60
+ - !ruby/object:Gem::Dependency
61
+ name: rexml
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - "~>"
65
+ - !ruby/object:Gem::Version
66
+ version: '3.3'
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: 3.3.6
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - "~>"
75
+ - !ruby/object:Gem::Version
76
+ version: '3.3'
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: 3.3.6
80
+ - !ruby/object:Gem::Dependency
81
+ name: facets
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '3.1'
87
+ type: :runtime
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '3.1'
94
+ - !ruby/object:Gem::Dependency
95
+ name: loofah
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '2.20'
101
+ type: :runtime
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '2.20'
74
108
  description: 'Otto: Auto-define your rack-apps in plaintext.'
75
109
  email: gems@solutious.com
76
110
  executables: []
@@ -88,6 +122,7 @@ files:
88
122
  - Gemfile.lock
89
123
  - LICENSE.txt
90
124
  - README.md
125
+ - bin/rspec
91
126
  - docs/.gitignore
92
127
  - examples/basic/app.rb
93
128
  - examples/basic/config.ru
@@ -98,14 +133,26 @@ files:
98
133
  - examples/helpers_demo/app.rb
99
134
  - examples/helpers_demo/config.ru
100
135
  - examples/helpers_demo/routes
136
+ - examples/mcp_demo/app.rb
137
+ - examples/mcp_demo/config.ru
138
+ - examples/mcp_demo/routes
101
139
  - examples/security_features/app.rb
102
140
  - examples/security_features/config.ru
103
141
  - examples/security_features/routes
142
+ - lib/concurrent_cache_store.rb
104
143
  - lib/otto.rb
105
144
  - lib/otto/design_system.rb
106
145
  - lib/otto/helpers/base.rb
107
146
  - lib/otto/helpers/request.rb
108
147
  - lib/otto/helpers/response.rb
148
+ - lib/otto/helpers/validation.rb
149
+ - lib/otto/mcp/auth/token.rb
150
+ - lib/otto/mcp/protocol.rb
151
+ - lib/otto/mcp/rate_limiting.rb
152
+ - lib/otto/mcp/registry.rb
153
+ - lib/otto/mcp/route_parser.rb
154
+ - lib/otto/mcp/server.rb
155
+ - lib/otto/mcp/validation.rb
109
156
  - lib/otto/response_handlers.rb
110
157
  - lib/otto/route.rb
111
158
  - lib/otto/route_definition.rb
@@ -113,6 +160,7 @@ files:
113
160
  - lib/otto/security/authentication.rb
114
161
  - lib/otto/security/config.rb
115
162
  - lib/otto/security/csrf.rb
163
+ - lib/otto/security/rate_limiting.rb
116
164
  - lib/otto/security/validator.rb
117
165
  - lib/otto/static.rb
118
166
  - lib/otto/version.rb