otto 1.6.0 → 2.0.0.pre2
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 +3 -2
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +26 -344
- data/CHANGELOG.rst +131 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +11 -4
- data/Gemfile.lock +38 -42
- data/README.md +2 -0
- data/bin/rspec +4 -4
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +2 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -0
- data/docs/migrating/v2.0.0-pre2.md +345 -0
- data/examples/.gitignore +1 -0
- data/examples/advanced_routes/README.md +33 -0
- data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
- data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
- data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
- data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
- data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
- data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
- data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
- data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
- data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
- data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
- data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
- data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
- data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
- data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
- data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
- data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
- data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
- data/examples/advanced_routes/app.rb +33 -0
- data/examples/advanced_routes/config.rb +23 -0
- data/examples/advanced_routes/config.ru +7 -0
- data/examples/advanced_routes/puma.rb +20 -0
- data/examples/advanced_routes/routes +167 -0
- data/examples/advanced_routes/run.rb +39 -0
- data/examples/advanced_routes/test.rb +58 -0
- data/examples/authentication_strategies/README.md +32 -0
- data/examples/authentication_strategies/app/auth.rb +68 -0
- data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
- data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
- data/examples/authentication_strategies/config.ru +24 -0
- data/examples/authentication_strategies/routes +37 -0
- data/examples/basic/README.md +29 -0
- data/examples/basic/app.rb +7 -35
- data/examples/basic/routes +0 -9
- data/examples/mcp_demo/README.md +87 -0
- data/examples/mcp_demo/app.rb +29 -34
- data/examples/mcp_demo/config.ru +9 -60
- data/examples/security_features/README.md +46 -0
- data/examples/security_features/app.rb +23 -24
- data/examples/security_features/config.ru +8 -10
- data/lib/otto/core/configuration.rb +167 -0
- data/lib/otto/core/error_handler.rb +86 -0
- data/lib/otto/core/file_safety.rb +61 -0
- data/lib/otto/core/middleware_stack.rb +237 -0
- data/lib/otto/core/router.rb +184 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/env_keys.rb +114 -0
- data/lib/otto/helpers/base.rb +5 -21
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +27 -4
- data/lib/otto/helpers/validation.rb +9 -7
- data/lib/otto/mcp/auth/token.rb +10 -9
- data/lib/otto/mcp/protocol.rb +24 -27
- data/lib/otto/mcp/rate_limiting.rb +8 -3
- data/lib/otto/mcp/registry.rb +7 -2
- data/lib/otto/mcp/route_parser.rb +10 -15
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +16 -11
- data/lib/otto/mcp/server.rb +45 -22
- data/lib/otto/response_handlers/auto.rb +39 -0
- data/lib/otto/response_handlers/base.rb +16 -0
- data/lib/otto/response_handlers/default.rb +16 -0
- data/lib/otto/response_handlers/factory.rb +39 -0
- data/lib/otto/response_handlers/json.rb +34 -0
- data/lib/otto/response_handlers/redirect.rb +25 -0
- data/lib/otto/response_handlers/view.rb +24 -0
- data/lib/otto/response_handlers.rb +9 -135
- data/lib/otto/route.rb +51 -55
- data/lib/otto/route_definition.rb +15 -18
- data/lib/otto/route_handlers/base.rb +121 -0
- data/lib/otto/route_handlers/class_method.rb +89 -0
- data/lib/otto/route_handlers/factory.rb +42 -0
- data/lib/otto/route_handlers/instance_method.rb +69 -0
- data/lib/otto/route_handlers/lambda.rb +59 -0
- data/lib/otto/route_handlers/logic_class.rb +93 -0
- data/lib/otto/route_handlers.rb +10 -405
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +140 -0
- data/lib/otto/security/authentication/failure_result.rb +44 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +19 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
- data/lib/otto/security/authentication/strategy_result.rb +337 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +14 -23
- data/lib/otto/security/configurator.rb +219 -0
- data/lib/otto/security/csrf.rb +8 -143
- data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +54 -0
- data/lib/otto/security/middleware/validation_middleware.rb +252 -0
- data/lib/otto/security/rate_limiter.rb +86 -0
- data/lib/otto/security/rate_limiting.rb +10 -105
- data/lib/otto/security/validator.rb +8 -253
- data/lib/otto/static.rb +3 -0
- data/lib/otto/utils.rb +14 -0
- data/lib/otto/version.rb +3 -1
- data/lib/otto.rb +141 -498
- data/otto.gemspec +4 -2
- metadata +99 -18
- data/examples/dynamic_pages/app.rb +0 -115
- data/examples/dynamic_pages/config.ru +0 -30
- data/examples/dynamic_pages/routes +0 -21
- data/examples/helpers_demo/app.rb +0 -244
- data/examples/helpers_demo/config.ru +0 -26
- data/examples/helpers_demo/routes +0 -7
- data/lib/concurrent_cache_store.rb +0 -68
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# lib/otto/env_keys.rb
|
|
2
|
+
#
|
|
3
|
+
# Central registry of all env['otto.*'] keys used throughout Otto framework.
|
|
4
|
+
# This documentation helps prevent key conflicts and aids multi-app integration.
|
|
5
|
+
#
|
|
6
|
+
# DOCUMENTATION-ONLY MODULE: The constants defined here are intentionally NOT used
|
|
7
|
+
# in the codebase. Otto uses string literals (e.g., env['otto.strategy_result'])
|
|
8
|
+
# for readibility/simplicity. This module exists as reference documentation but
|
|
9
|
+
# may be considered for future use if needed.
|
|
10
|
+
#
|
|
11
|
+
class Otto
|
|
12
|
+
# Rack environment keys used by Otto framework
|
|
13
|
+
#
|
|
14
|
+
# All Otto-specific keys are namespaced under 'otto.*' to avoid conflicts
|
|
15
|
+
# with other Rack middleware or applications.
|
|
16
|
+
module EnvKeys
|
|
17
|
+
# =========================================================================
|
|
18
|
+
# ROUTING & REQUEST FLOW
|
|
19
|
+
# =========================================================================
|
|
20
|
+
|
|
21
|
+
# Route definition parsed from routes file
|
|
22
|
+
# Type: Otto::RouteDefinition
|
|
23
|
+
# Set by: Otto::Core::Router#parse_routes
|
|
24
|
+
# Used by: AuthenticationMiddleware, RouteHandlers, LogicClassHandler
|
|
25
|
+
ROUTE_DEFINITION = 'otto.route_definition'
|
|
26
|
+
|
|
27
|
+
# Route-specific options parsed from route string
|
|
28
|
+
# Type: Hash (e.g., { response: 'json', csrf: 'exempt', auth: 'authenticated' })
|
|
29
|
+
# Set by: Otto::RouteDefinition#initialize
|
|
30
|
+
# Used by: CSRFMiddleware, RouteHandlers
|
|
31
|
+
ROUTE_OPTIONS = 'otto.route_options'
|
|
32
|
+
|
|
33
|
+
# =========================================================================
|
|
34
|
+
# AUTHENTICATION & AUTHORIZATION
|
|
35
|
+
# =========================================================================
|
|
36
|
+
|
|
37
|
+
# Authentication strategy result containing session/user state
|
|
38
|
+
# Type: Otto::Security::Authentication::StrategyResult
|
|
39
|
+
# Set by: AuthenticationMiddleware
|
|
40
|
+
# Used by: RouteHandlers, LogicClasses, Controllers
|
|
41
|
+
# Note: Always present (anonymous or authenticated)
|
|
42
|
+
STRATEGY_RESULT = 'otto.strategy_result'
|
|
43
|
+
|
|
44
|
+
# Authenticated user object (convenience accessor)
|
|
45
|
+
# Type: Hash, Custom User Object, or nil
|
|
46
|
+
# Set by: AuthenticationMiddleware (from strategy_result.user)
|
|
47
|
+
# Used by: Controllers, RouteHandlers
|
|
48
|
+
USER = 'otto.user'
|
|
49
|
+
|
|
50
|
+
# User-specific context (session, roles, permissions, etc.)
|
|
51
|
+
# Type: Hash
|
|
52
|
+
# Set by: AuthenticationMiddleware (from strategy_result.user_context)
|
|
53
|
+
# Used by: Controllers, Analytics
|
|
54
|
+
USER_CONTEXT = 'otto.user_context'
|
|
55
|
+
|
|
56
|
+
# =========================================================================
|
|
57
|
+
# SECURITY & CONFIGURATION
|
|
58
|
+
# =========================================================================
|
|
59
|
+
|
|
60
|
+
# Security configuration object
|
|
61
|
+
# Type: Otto::Security::Config
|
|
62
|
+
# Set by: Otto#initialize, SecurityConfig
|
|
63
|
+
# Used by: All security middleware (CSRF, Headers, Validation)
|
|
64
|
+
SECURITY_CONFIG = 'otto.security_config'
|
|
65
|
+
|
|
66
|
+
# =========================================================================
|
|
67
|
+
# LOCALIZATION (I18N)
|
|
68
|
+
# =========================================================================
|
|
69
|
+
|
|
70
|
+
# Resolved locale for current request
|
|
71
|
+
# Type: String (e.g., 'en', 'es', 'fr')
|
|
72
|
+
# Set by: LocaleMiddleware
|
|
73
|
+
# Used by: RouteHandlers, LogicClasses, Views
|
|
74
|
+
LOCALE = 'otto.locale'
|
|
75
|
+
|
|
76
|
+
# Locale configuration object
|
|
77
|
+
# Type: Otto::LocaleConfig
|
|
78
|
+
# Set by: LocaleMiddleware
|
|
79
|
+
# Used by: Locale resolution logic
|
|
80
|
+
LOCALE_CONFIG = 'otto.locale_config'
|
|
81
|
+
|
|
82
|
+
# Available locales for the application
|
|
83
|
+
# Type: Array<String>
|
|
84
|
+
# Set by: LocaleConfig
|
|
85
|
+
# Used by: Locale middleware, language switchers
|
|
86
|
+
AVAILABLE_LOCALES = 'otto.available_locales'
|
|
87
|
+
|
|
88
|
+
# Default/fallback locale
|
|
89
|
+
# Type: String
|
|
90
|
+
# Set by: LocaleConfig
|
|
91
|
+
# Used by: Locale middleware when resolution fails
|
|
92
|
+
DEFAULT_LOCALE = 'otto.default_locale'
|
|
93
|
+
|
|
94
|
+
# =========================================================================
|
|
95
|
+
# ERROR HANDLING
|
|
96
|
+
# =========================================================================
|
|
97
|
+
|
|
98
|
+
# Unique error ID for tracking/logging
|
|
99
|
+
# Type: String (hex format, e.g., '4ac47cb3a6d177ef')
|
|
100
|
+
# Set by: ErrorHandler, RouteHandlers
|
|
101
|
+
# Used by: Error responses, logging, support
|
|
102
|
+
ERROR_ID = 'otto.error_id'
|
|
103
|
+
|
|
104
|
+
# =========================================================================
|
|
105
|
+
# MCP (MODEL CONTEXT PROTOCOL)
|
|
106
|
+
# =========================================================================
|
|
107
|
+
|
|
108
|
+
# MCP HTTP endpoint path
|
|
109
|
+
# Type: String (default: '/_mcp')
|
|
110
|
+
# Set by: Otto::MCP::Server#enable!
|
|
111
|
+
# Used by: MCP middleware, SchemaValidationMiddleware
|
|
112
|
+
MCP_HTTP_ENDPOINT = 'otto.mcp_http_endpoint'
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/otto/helpers/base.rb
CHANGED
|
@@ -1,27 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/otto/helpers/base.rb
|
|
2
4
|
|
|
3
5
|
class Otto
|
|
6
|
+
# Base helper methods providing core functionality for Otto applications
|
|
4
7
|
module BaseHelpers
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# This method safely joins multiple path segments, handling
|
|
8
|
-
# duplicate slashes and ensuring proper path formatting.
|
|
9
|
-
# Includes the script name (mount point) as the first segment.
|
|
10
|
-
#
|
|
11
|
-
# @param paths [Array<String>] Path segments to join
|
|
12
|
-
# @return [String] Properly formatted path
|
|
13
|
-
#
|
|
14
|
-
# @example
|
|
15
|
-
# app_path('api', 'v1', 'users')
|
|
16
|
-
# # => "/myapp/api/v1/users"
|
|
17
|
-
#
|
|
18
|
-
# @example
|
|
19
|
-
# app_path(['admin', 'settings'])
|
|
20
|
-
# # => "/myapp/admin/settings"
|
|
21
|
-
def app_path(*paths)
|
|
22
|
-
paths = paths.flatten.compact
|
|
23
|
-
paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
|
|
24
|
-
paths.join('/').gsub('//', '/')
|
|
25
|
-
end
|
|
8
|
+
# Keep only truly context-independent shared functionality here
|
|
9
|
+
# Methods requiring env access should be implemented in the specific helper modules
|
|
26
10
|
end
|
|
27
11
|
end
|
data/lib/otto/helpers/request.rb
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/otto/helpers/request.rb
|
|
2
4
|
|
|
3
5
|
require_relative 'base'
|
|
4
6
|
|
|
5
7
|
class Otto
|
|
8
|
+
# Request helper methods providing HTTP request handling utilities
|
|
6
9
|
module RequestHelpers
|
|
7
10
|
include Otto::BaseHelpers
|
|
8
11
|
|
|
@@ -14,9 +17,7 @@ class Otto
|
|
|
14
17
|
remote_addr = env['REMOTE_ADDR']
|
|
15
18
|
|
|
16
19
|
# If we don't have a security config or trusted proxies, use direct connection
|
|
17
|
-
if !otto_security_config || !trusted_proxy?(remote_addr)
|
|
18
|
-
return validate_ip_address(remote_addr)
|
|
19
|
-
end
|
|
20
|
+
return validate_ip_address(remote_addr) if !otto_security_config || !trusted_proxy?(remote_addr)
|
|
20
21
|
|
|
21
22
|
# Check forwarded headers from trusted proxies
|
|
22
23
|
forwarded_ips = [
|
|
@@ -152,12 +153,12 @@ class Otto
|
|
|
152
153
|
|
|
153
154
|
# RFC 1918 private ranges and loopback
|
|
154
155
|
private_ranges = [
|
|
155
|
-
/\A10\./,
|
|
156
|
+
/\A10\./, # 10.0.0.0/8
|
|
156
157
|
/\A172\.(1[6-9]|2[0-9]|3[01])\./, # 172.16.0.0/12
|
|
157
158
|
/\A192\.168\./, # 192.168.0.0/16
|
|
158
159
|
/\A169\.254\./, # 169.254.0.0/16 (link-local)
|
|
159
160
|
/\A224\./, # 224.0.0.0/4 (multicast)
|
|
160
|
-
/\A0\./,
|
|
161
|
+
/\A0\./, # 0.0.0.0/8
|
|
161
162
|
]
|
|
162
163
|
|
|
163
164
|
private_ranges.any? { |range| ip.match?(range) }
|
|
@@ -207,7 +208,7 @@ class Otto
|
|
|
207
208
|
|
|
208
209
|
# Add any header that begins with the specified prefix
|
|
209
210
|
if header_prefix
|
|
210
|
-
prefix_keys = env.keys.select {
|
|
211
|
+
prefix_keys = env.keys.select { _1.upcase.start_with?("HTTP_#{header_prefix.upcase}") }
|
|
211
212
|
keys.concat(prefix_keys)
|
|
212
213
|
end
|
|
213
214
|
|
|
@@ -354,8 +355,9 @@ class Otto
|
|
|
354
355
|
debug_enabled = opts[:debug] || false
|
|
355
356
|
|
|
356
357
|
# Guard clause - required configuration must be present
|
|
357
|
-
unless available_locales && default_locale
|
|
358
|
-
raise ArgumentError,
|
|
358
|
+
unless available_locales.is_a?(Hash) && !available_locales.empty? && default_locale && available_locales.key?(default_locale)
|
|
359
|
+
raise ArgumentError,
|
|
360
|
+
'available_locales must be a non-empty Hash and include default_locale (provide via opts or Otto configuration)'
|
|
359
361
|
end
|
|
360
362
|
|
|
361
363
|
# Check sources in order of precedence
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/otto/helpers/response.rb
|
|
2
4
|
|
|
3
5
|
require_relative 'base'
|
|
4
6
|
|
|
5
7
|
class Otto
|
|
8
|
+
# Response helper methods providing HTTP response handling utilities
|
|
6
9
|
module ResponseHelpers
|
|
7
10
|
include Otto::BaseHelpers
|
|
8
11
|
|
|
@@ -48,7 +51,7 @@ class Otto
|
|
|
48
51
|
session_opts = opts.merge(
|
|
49
52
|
secure: true,
|
|
50
53
|
httponly: true,
|
|
51
|
-
samesite: :strict
|
|
54
|
+
samesite: :strict
|
|
52
55
|
)
|
|
53
56
|
|
|
54
57
|
# Remove expiration-related options for session cookies
|
|
@@ -109,9 +112,7 @@ class Otto
|
|
|
109
112
|
headers['content-type'] ||= content_type
|
|
110
113
|
|
|
111
114
|
# Warn if CSP header already exists but don't skip
|
|
112
|
-
if headers['content-security-policy']
|
|
113
|
-
warn 'CSP header already set, overriding with nonce-based policy'
|
|
114
|
-
end
|
|
115
|
+
warn 'CSP header already set, overriding with nonce-based policy' if headers['content-security-policy']
|
|
115
116
|
|
|
116
117
|
# Get security configuration
|
|
117
118
|
security_config = opts[:security_config] ||
|
|
@@ -151,5 +152,27 @@ class Otto
|
|
|
151
152
|
headers['expires'] = 'Mon, 7 Nov 2011 00:00:00 UTC'
|
|
152
153
|
headers['pragma'] = 'no-cache'
|
|
153
154
|
end
|
|
155
|
+
|
|
156
|
+
# Build application path by joining path segments
|
|
157
|
+
#
|
|
158
|
+
# This method safely joins multiple path segments, handling
|
|
159
|
+
# duplicate slashes and ensuring proper path formatting.
|
|
160
|
+
# Includes the script name (mount point) as the first segment.
|
|
161
|
+
#
|
|
162
|
+
# @param paths [Array<String>] Path segments to join
|
|
163
|
+
# @return [String] Properly formatted path
|
|
164
|
+
#
|
|
165
|
+
# @example
|
|
166
|
+
# app_path('api', 'v1', 'users')
|
|
167
|
+
# # => "/myapp/api/v1/users"
|
|
168
|
+
#
|
|
169
|
+
# @example
|
|
170
|
+
# app_path(['admin', 'settings'])
|
|
171
|
+
# # => "/myapp/admin/settings"
|
|
172
|
+
def app_path(*paths)
|
|
173
|
+
paths = paths.flatten.compact
|
|
174
|
+
paths.unshift(request.env['SCRIPT_NAME']) if request.env['SCRIPT_NAME']
|
|
175
|
+
paths.join('/').gsub('//', '/')
|
|
176
|
+
end
|
|
154
177
|
end
|
|
155
178
|
end
|
|
@@ -1,7 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
# lib/otto/helpers/validation.rb
|
|
2
4
|
|
|
5
|
+
require 'loofah'
|
|
6
|
+
require 'facets/file'
|
|
7
|
+
|
|
3
8
|
class Otto
|
|
4
9
|
module Security
|
|
10
|
+
# Validation helper methods providing input validation and sanitization
|
|
5
11
|
module ValidationHelpers
|
|
6
12
|
def validate_input(input, max_length: 1000, allow_html: false)
|
|
7
13
|
return input if input.nil?
|
|
@@ -17,9 +23,7 @@ class Otto
|
|
|
17
23
|
# Use Loofah for HTML sanitization and validation
|
|
18
24
|
unless allow_html
|
|
19
25
|
# Check for script injection first (these should always be rejected)
|
|
20
|
-
if looks_like_script_injection?(input_str)
|
|
21
|
-
raise Otto::Security::ValidationError, 'Dangerous content detected'
|
|
22
|
-
end
|
|
26
|
+
raise Otto::Security::ValidationError, 'Dangerous content detected' if looks_like_script_injection?(input_str)
|
|
23
27
|
|
|
24
28
|
# Use Loofah to sanitize less dangerous HTML content
|
|
25
29
|
sanitized_input = Loofah.fragment(input_str).scrub!(:whitewash).to_s
|
|
@@ -28,9 +32,7 @@ class Otto
|
|
|
28
32
|
|
|
29
33
|
# Always check for SQL injection
|
|
30
34
|
ValidationMiddleware::SQL_INJECTION_PATTERNS.each do |pattern|
|
|
31
|
-
if input_str.match?(pattern)
|
|
32
|
-
raise Otto::Security::ValidationError, 'Potential SQL injection detected'
|
|
33
|
-
end
|
|
35
|
+
raise Otto::Security::ValidationError, 'Potential SQL injection detected' if input_str.match?(pattern)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
input_str
|
|
@@ -71,7 +73,7 @@ class Otto
|
|
|
71
73
|
dangerous_patterns = [
|
|
72
74
|
/javascript:/i,
|
|
73
75
|
/<script[^>]*>/i,
|
|
74
|
-
/on\w+\s*=/i,
|
|
76
|
+
/on\w+\s*=/i, # event handlers like onclick=
|
|
75
77
|
/expression\s*\(/i,
|
|
76
78
|
/data:.*base64/i,
|
|
77
79
|
]
|
data/lib/otto/mcp/auth/token.rb
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/mcp/auth/token.rb
|
|
4
|
+
|
|
1
5
|
require 'json'
|
|
2
6
|
|
|
3
7
|
class Otto
|
|
4
8
|
module MCP
|
|
5
9
|
module Auth
|
|
10
|
+
# Token-based authentication for MCP protocol endpoints
|
|
6
11
|
class TokenAuth
|
|
7
12
|
def initialize(tokens)
|
|
8
13
|
@tokens = Array(tokens).to_set
|
|
@@ -20,15 +25,14 @@ class Otto
|
|
|
20
25
|
def extract_token(env)
|
|
21
26
|
# Try Authorization header first (Bearer token)
|
|
22
27
|
auth_header = env['HTTP_AUTHORIZATION']
|
|
23
|
-
if auth_header&.start_with?('Bearer ')
|
|
24
|
-
return auth_header[7..]
|
|
25
|
-
end
|
|
28
|
+
return auth_header[7..] if auth_header&.start_with?('Bearer ')
|
|
26
29
|
|
|
27
30
|
# Try X-MCP-Token header
|
|
28
31
|
env['HTTP_X_MCP_TOKEN']
|
|
29
32
|
end
|
|
30
33
|
end
|
|
31
34
|
|
|
35
|
+
# Middleware for token authentication in MCP protocol
|
|
32
36
|
class TokenMiddleware
|
|
33
37
|
def initialize(app, security_config = nil)
|
|
34
38
|
@app = app
|
|
@@ -41,9 +45,7 @@ class Otto
|
|
|
41
45
|
|
|
42
46
|
# Get auth instance from security config
|
|
43
47
|
auth = @security_config&.mcp_auth
|
|
44
|
-
if auth && !auth.authenticate(env)
|
|
45
|
-
return unauthorized_response
|
|
46
|
-
end
|
|
48
|
+
return unauthorized_response if auth && !auth.authenticate(env)
|
|
47
49
|
|
|
48
50
|
@app.call(env)
|
|
49
51
|
end
|
|
@@ -58,15 +60,14 @@ class Otto
|
|
|
58
60
|
|
|
59
61
|
def unauthorized_response
|
|
60
62
|
body = JSON.generate({
|
|
61
|
-
|
|
63
|
+
jsonrpc: '2.0',
|
|
62
64
|
id: nil,
|
|
63
65
|
error: {
|
|
64
66
|
code: -32_000,
|
|
65
67
|
message: 'Unauthorized',
|
|
66
68
|
data: 'Valid token required',
|
|
67
69
|
},
|
|
68
|
-
|
|
69
|
-
)
|
|
70
|
+
})
|
|
70
71
|
|
|
71
72
|
[401, { 'content-type' => 'application/json' }, [body]]
|
|
72
73
|
end
|
data/lib/otto/mcp/protocol.rb
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/mcp/protocol.rb
|
|
4
|
+
|
|
1
5
|
require 'json'
|
|
2
6
|
require_relative 'registry'
|
|
3
7
|
|
|
4
8
|
class Otto
|
|
5
9
|
module MCP
|
|
10
|
+
# MCP protocol handler providing Model Context Protocol functionality
|
|
6
11
|
class Protocol
|
|
7
12
|
attr_reader :registry
|
|
8
13
|
|
|
@@ -64,14 +69,13 @@ class Otto
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
success_response(data['id'], {
|
|
67
|
-
|
|
72
|
+
protocolVersion: '2024-11-05',
|
|
68
73
|
capabilities: capabilities,
|
|
69
74
|
serverInfo: {
|
|
70
75
|
name: 'Otto MCP Server',
|
|
71
76
|
version: Otto::VERSION,
|
|
72
77
|
},
|
|
73
|
-
|
|
74
|
-
)
|
|
78
|
+
})
|
|
75
79
|
end
|
|
76
80
|
|
|
77
81
|
def handle_resources_list(data)
|
|
@@ -83,9 +87,7 @@ class Otto
|
|
|
83
87
|
params = data['params'] || {}
|
|
84
88
|
uri = params['uri']
|
|
85
89
|
|
|
86
|
-
unless uri
|
|
87
|
-
return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter')
|
|
88
|
-
end
|
|
90
|
+
return error_response(data['id'], -32_602, 'Invalid params', 'Missing uri parameter') unless uri
|
|
89
91
|
|
|
90
92
|
resource = @registry.read_resource(uri)
|
|
91
93
|
if resource
|
|
@@ -105,26 +107,23 @@ class Otto
|
|
|
105
107
|
name = params['name']
|
|
106
108
|
arguments = params['arguments'] || {}
|
|
107
109
|
|
|
108
|
-
unless name
|
|
109
|
-
return error_response(data['id'], -32_602, 'Invalid params', 'Missing name parameter')
|
|
110
|
-
end
|
|
110
|
+
return error_response(data['id'], -32_602, 'Invalid params', 'Missing name parameter') unless name
|
|
111
111
|
|
|
112
112
|
begin
|
|
113
113
|
result = @registry.call_tool(name, arguments, env)
|
|
114
114
|
success_response(data['id'], result)
|
|
115
|
-
rescue StandardError =>
|
|
116
|
-
Otto.logger.error "[MCP] Tool call error: #{
|
|
117
|
-
error_response(data['id'], -32_603, 'Internal error',
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
Otto.logger.error "[MCP] Tool call error: #{e.message}"
|
|
117
|
+
error_response(data['id'], -32_603, 'Internal error', e.message)
|
|
118
118
|
end
|
|
119
119
|
end
|
|
120
120
|
|
|
121
121
|
def success_response(id, result)
|
|
122
122
|
body = JSON.generate({
|
|
123
|
-
|
|
123
|
+
jsonrpc: '2.0',
|
|
124
124
|
id: id,
|
|
125
125
|
result: result,
|
|
126
|
-
|
|
127
|
-
)
|
|
126
|
+
})
|
|
128
127
|
|
|
129
128
|
[200, { 'content-type' => 'application/json' }, [body]]
|
|
130
129
|
end
|
|
@@ -134,30 +133,28 @@ class Otto
|
|
|
134
133
|
error[:data] = data if data
|
|
135
134
|
|
|
136
135
|
body = JSON.generate({
|
|
137
|
-
|
|
136
|
+
jsonrpc: '2.0',
|
|
138
137
|
id: id,
|
|
139
138
|
error: error,
|
|
140
|
-
|
|
141
|
-
)
|
|
139
|
+
})
|
|
142
140
|
|
|
143
141
|
# Map JSON-RPC error codes to appropriate HTTP status codes
|
|
144
142
|
http_status = case code
|
|
145
|
-
when -
|
|
143
|
+
when -32_700..-32_600 # Parse error, Invalid Request, Method not found
|
|
146
144
|
400
|
|
147
|
-
when -
|
|
145
|
+
when -32_603, -32_000..-32_099 # Internal error and all server error range (-32000..-32099)
|
|
148
146
|
500
|
|
149
|
-
when -
|
|
147
|
+
when -32_001 # Resource not found
|
|
150
148
|
404
|
|
151
|
-
when -
|
|
149
|
+
when -32_002 # Tool not found
|
|
152
150
|
404
|
|
153
|
-
when -
|
|
151
|
+
when -32_601 # Method not found
|
|
154
152
|
404
|
|
155
|
-
when -
|
|
153
|
+
when -32_602 # Invalid params
|
|
156
154
|
400
|
|
157
|
-
when -32603 # Internal error
|
|
158
|
-
500
|
|
159
155
|
else
|
|
160
|
-
|
|
156
|
+
# Default client error for unknown non-server codes; treat server-range as 500
|
|
157
|
+
(-32_099..-32_000).cover?(code) ? 500 : 400
|
|
161
158
|
end
|
|
162
159
|
|
|
163
160
|
[http_status, { 'content-type' => 'application/json' }, [body]]
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/mcp/rate_limiting.rb
|
|
4
|
+
|
|
1
5
|
require 'json'
|
|
2
6
|
|
|
3
7
|
require_relative '../security/rate_limiting'
|
|
@@ -10,6 +14,7 @@ end
|
|
|
10
14
|
|
|
11
15
|
class Otto
|
|
12
16
|
module MCP
|
|
17
|
+
# Rate limiter for MCP protocol endpoints
|
|
13
18
|
class RateLimiter < Otto::Security::RateLimiting
|
|
14
19
|
def self.configure_rack_attack!(config = {})
|
|
15
20
|
return unless defined?(Rack::Attack)
|
|
@@ -117,6 +122,7 @@ class Otto
|
|
|
117
122
|
end
|
|
118
123
|
end
|
|
119
124
|
|
|
125
|
+
# Middleware for applying rate limits to MCP protocol endpoints
|
|
120
126
|
class RateLimitMiddleware < Otto::Security::RateLimitMiddleware
|
|
121
127
|
def initialize(app, security_config = nil)
|
|
122
128
|
@app = app
|
|
@@ -138,10 +144,9 @@ class Otto
|
|
|
138
144
|
|
|
139
145
|
# Add MCP-specific defaults
|
|
140
146
|
mcp_config = base_config.merge({
|
|
141
|
-
|
|
147
|
+
mcp_requests_per_minute: 60,
|
|
142
148
|
tool_calls_per_minute: 20,
|
|
143
|
-
|
|
144
|
-
)
|
|
149
|
+
})
|
|
145
150
|
|
|
146
151
|
RateLimiter.configure_rack_attack!(mcp_config)
|
|
147
152
|
end
|
data/lib/otto/mcp/registry.rb
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/mcp/registry.rb
|
|
4
|
+
|
|
1
5
|
class Otto
|
|
2
6
|
module MCP
|
|
7
|
+
# Registry for managing MCP resources and tools
|
|
3
8
|
class Registry
|
|
4
9
|
def initialize
|
|
5
10
|
@resources = {}
|
|
@@ -59,8 +64,8 @@ class Otto
|
|
|
59
64
|
text: content.to_s,
|
|
60
65
|
}],
|
|
61
66
|
}
|
|
62
|
-
rescue StandardError =>
|
|
63
|
-
Otto.logger.error "[MCP] Resource read error for #{uri}: #{
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Otto.logger.error "[MCP] Resource read error for #{uri}: #{e.message}"
|
|
64
69
|
nil
|
|
65
70
|
end
|
|
66
71
|
end
|
|
@@ -1,21 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/mcp/route_parser.rb
|
|
4
|
+
|
|
1
5
|
class Otto
|
|
2
6
|
module MCP
|
|
7
|
+
# Parser for MCP route definitions and resource URIs
|
|
3
8
|
class RouteParser
|
|
4
9
|
def self.parse_mcp_route(_verb, _path, definition)
|
|
5
10
|
# MCP route format: MCP resource_uri HandlerClass.method_name
|
|
6
11
|
# Note: The path parameter is ignored for MCP routes - resource_uri comes from definition
|
|
7
12
|
parts = definition.split(/\s+/, 3)
|
|
8
13
|
|
|
9
|
-
if parts[0] != 'MCP'
|
|
10
|
-
raise ArgumentError, "Expected MCP keyword, got: #{parts[0]}"
|
|
11
|
-
end
|
|
14
|
+
raise ArgumentError, "Expected MCP keyword, got: #{parts[0]}" if parts[0] != 'MCP'
|
|
12
15
|
|
|
13
16
|
resource_uri = parts[1]
|
|
14
17
|
handler_definition = parts[2]
|
|
15
18
|
|
|
16
|
-
unless resource_uri && handler_definition
|
|
17
|
-
raise ArgumentError, "Invalid MCP route format: #{definition}"
|
|
18
|
-
end
|
|
19
|
+
raise ArgumentError, "Invalid MCP route format: #{definition}" unless resource_uri && handler_definition
|
|
19
20
|
|
|
20
21
|
# Clean up URI - remove leading slash if present since MCP URIs are relative
|
|
21
22
|
resource_uri = resource_uri.sub(%r{^/}, '')
|
|
@@ -33,16 +34,12 @@ class Otto
|
|
|
33
34
|
# Note: The path parameter is ignored for TOOL routes - tool_name comes from definition
|
|
34
35
|
parts = definition.split(/\s+/, 3)
|
|
35
36
|
|
|
36
|
-
if parts[0] != 'TOOL'
|
|
37
|
-
raise ArgumentError, "Expected TOOL keyword, got: #{parts[0]}"
|
|
38
|
-
end
|
|
37
|
+
raise ArgumentError, "Expected TOOL keyword, got: #{parts[0]}" if parts[0] != 'TOOL'
|
|
39
38
|
|
|
40
39
|
tool_name = parts[1]
|
|
41
40
|
handler_definition = parts[2]
|
|
42
41
|
|
|
43
|
-
unless tool_name && handler_definition
|
|
44
|
-
raise ArgumentError, "Invalid TOOL route format: #{definition}"
|
|
45
|
-
end
|
|
42
|
+
raise ArgumentError, "Invalid TOOL route format: #{definition}" unless tool_name && handler_definition
|
|
46
43
|
|
|
47
44
|
# Clean up tool name - remove leading slash if present
|
|
48
45
|
tool_name = tool_name.sub(%r{^/}, '')
|
|
@@ -70,9 +67,7 @@ class Otto
|
|
|
70
67
|
# First part is the handler class.method
|
|
71
68
|
parts[1..-1]&.each do |part|
|
|
72
69
|
key, value = part.split('=', 2)
|
|
73
|
-
if key && value
|
|
74
|
-
options[key.to_sym] = value
|
|
75
|
-
end
|
|
70
|
+
options[key.to_sym] = value if key && value
|
|
76
71
|
end
|
|
77
72
|
|
|
78
73
|
options
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/otto/mcp/schema_validation.rb
|
|
4
|
+
|
|
1
5
|
require 'json'
|
|
2
6
|
|
|
3
7
|
begin
|
|
@@ -10,6 +14,7 @@ class Otto
|
|
|
10
14
|
module MCP
|
|
11
15
|
class ValidationError < StandardError; end
|
|
12
16
|
|
|
17
|
+
# JSON Schema validator for MCP protocol requests
|
|
13
18
|
class Validator
|
|
14
19
|
def initialize
|
|
15
20
|
@schemas = {}
|
|
@@ -48,7 +53,7 @@ class Otto
|
|
|
48
53
|
|
|
49
54
|
def mcp_request_schema
|
|
50
55
|
@schemas[:mcp_request] ||= JSONSchemer.schema({
|
|
51
|
-
|
|
56
|
+
type: 'object',
|
|
52
57
|
required: %w[jsonrpc method id],
|
|
53
58
|
properties: {
|
|
54
59
|
jsonrpc: { const: '2.0' },
|
|
@@ -57,12 +62,13 @@ class Otto
|
|
|
57
62
|
params: { type: 'object' },
|
|
58
63
|
},
|
|
59
64
|
additionalProperties: false,
|
|
60
|
-
|
|
61
|
-
)
|
|
65
|
+
})
|
|
62
66
|
end
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
|
|
69
|
+
# Middleware for validating MCP protocol requests using JSON schema
|
|
70
|
+
# Validates JSON-RPC 2.0 structure and tool argument schemas
|
|
71
|
+
class SchemaValidationMiddleware
|
|
66
72
|
def initialize(app, _security_config = nil)
|
|
67
73
|
@app = app
|
|
68
74
|
@validator = Validator.new
|
|
@@ -82,10 +88,10 @@ class Otto
|
|
|
82
88
|
|
|
83
89
|
# Reset body for downstream middleware
|
|
84
90
|
request.body.rewind if request.body.respond_to?(:rewind)
|
|
85
|
-
rescue JSON::ParserError =>
|
|
86
|
-
return validation_error_response(nil, "Invalid JSON: #{
|
|
87
|
-
rescue ValidationError =>
|
|
88
|
-
return validation_error_response(data&.dig('id'),
|
|
91
|
+
rescue JSON::ParserError => e
|
|
92
|
+
return validation_error_response(nil, "Invalid JSON: #{e.message}")
|
|
93
|
+
rescue ValidationError => e
|
|
94
|
+
return validation_error_response(data&.dig('id'), e.message)
|
|
89
95
|
end
|
|
90
96
|
end
|
|
91
97
|
|
|
@@ -102,15 +108,14 @@ class Otto
|
|
|
102
108
|
|
|
103
109
|
def validation_error_response(id, message)
|
|
104
110
|
body = JSON.generate({
|
|
105
|
-
|
|
111
|
+
jsonrpc: '2.0',
|
|
106
112
|
id: id,
|
|
107
113
|
error: {
|
|
108
114
|
code: -32_600,
|
|
109
115
|
message: 'Invalid Request',
|
|
110
116
|
data: message,
|
|
111
117
|
},
|
|
112
|
-
|
|
113
|
-
)
|
|
118
|
+
})
|
|
114
119
|
|
|
115
120
|
[400, { 'content-type' => 'application/json' }, [body]]
|
|
116
121
|
end
|