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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +43 -4
- data/.gitignore +1 -0
- data/.rubocop.yml +1 -1
- data/Gemfile +12 -3
- data/Gemfile.lock +51 -8
- data/bin/rspec +16 -0
- data/docs/.gitignore +2 -0
- data/examples/mcp_demo/app.rb +56 -0
- data/examples/mcp_demo/config.ru +68 -0
- data/examples/mcp_demo/routes +9 -0
- data/lib/concurrent_cache_store.rb +68 -0
- data/lib/otto/helpers/validation.rb +83 -0
- data/lib/otto/mcp/auth/token.rb +76 -0
- data/lib/otto/mcp/protocol.rb +167 -0
- data/lib/otto/mcp/rate_limiting.rb +150 -0
- data/lib/otto/mcp/registry.rb +95 -0
- data/lib/otto/mcp/route_parser.rb +82 -0
- data/lib/otto/mcp/server.rb +196 -0
- data/lib/otto/mcp/validation.rb +119 -0
- data/lib/otto/response_handlers.rb +141 -0
- data/lib/otto/route.rb +120 -54
- data/lib/otto/route_definition.rb +187 -0
- data/lib/otto/route_handlers.rb +412 -0
- data/lib/otto/security/authentication.rb +289 -0
- data/lib/otto/security/config.rb +3 -1
- data/lib/otto/security/rate_limiting.rb +111 -0
- data/lib/otto/security/validator.rb +35 -74
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +196 -3
- data/otto.gemspec +11 -6
- metadata +72 -19
data/lib/otto/security/config.rb
CHANGED
@@ -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
|
-
|
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
|
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
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
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
|
176
|
+
if sanitized.match?(pattern)
|
163
177
|
raise Otto::Security::ValidationError, 'Potential SQL injection detected'
|
164
178
|
end
|
165
179
|
end
|
166
180
|
|
167
|
-
|
168
|
-
|
169
|
-
raise Otto::Security::ValidationError, "Parameter value too long (#{value.length} characters)"
|
170
|
-
end
|
181
|
+
sanitized
|
182
|
+
end
|
171
183
|
|
172
|
-
|
173
|
-
sanitized = value.gsub(/\0/, '').gsub(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/, '')
|
184
|
+
include ValidationHelpers
|
174
185
|
|
175
|
-
|
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
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
|
103
|
+
raw = File.readlines(path).select { |line| line =~ /^\w/ }.collect { |line| line.strip }
|
91
104
|
raw.each do |entry|
|
92
|
-
|
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 =
|
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
|
-
|
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
|