otto 2.0.0.pre1 → 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 +2 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.rst +54 -6
- data/Gemfile +1 -1
- data/Gemfile.lock +19 -18
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre2.md +345 -0
- data/lib/otto/core/configuration.rb +2 -2
- data/lib/otto/core/middleware_stack.rb +80 -0
- data/lib/otto/core/router.rb +7 -6
- data/lib/otto/env_keys.rb +114 -0
- data/lib/otto/helpers/base.rb +2 -21
- data/lib/otto/helpers/response.rb +22 -0
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
- data/lib/otto/mcp/server.rb +26 -13
- data/lib/otto/response_handlers/json.rb +6 -0
- data/lib/otto/route.rb +44 -48
- data/lib/otto/route_handlers/factory.rb +22 -9
- data/lib/otto/security/authentication/authentication_middleware.rb +29 -12
- data/lib/otto/security/authentication/failure_result.rb +15 -7
- data/lib/otto/security/authentication/route_auth_wrapper.rb +149 -0
- data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +1 -1
- data/lib/otto/security/authentication/strategy_result.rb +129 -15
- data/lib/otto/security/authentication.rb +2 -2
- data/lib/otto/security/config.rb +0 -11
- data/lib/otto/security/configurator.rb +2 -2
- data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +2 -3
- data/otto.gemspec +2 -0
- metadata +26 -6
- data/changelog.d/20250911_235619_delano_next.rst +0 -28
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
@@ -36,6 +36,86 @@ class Otto
|
|
36
36
|
# Invalidate memoized middleware list
|
37
37
|
@memoized_middleware_list = nil
|
38
38
|
end
|
39
|
+
|
40
|
+
# Add middleware with position hint for optimal ordering
|
41
|
+
#
|
42
|
+
# @param middleware_class [Class] Middleware class
|
43
|
+
# @param args [Array] Middleware arguments
|
44
|
+
# @param position [Symbol, nil] Position hint (:first, :last, or nil for append)
|
45
|
+
# @option options [Symbol] :position Position hint (:first or :last)
|
46
|
+
def add_with_position(middleware_class, *args, position: nil, **options)
|
47
|
+
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
48
|
+
|
49
|
+
# Check for identical configuration
|
50
|
+
existing_entry = @stack.find do |entry|
|
51
|
+
entry[:middleware] == middleware_class &&
|
52
|
+
entry[:args] == args &&
|
53
|
+
entry[:options] == options
|
54
|
+
end
|
55
|
+
|
56
|
+
return if existing_entry
|
57
|
+
|
58
|
+
entry = { middleware: middleware_class, args: args, options: options }
|
59
|
+
|
60
|
+
case position
|
61
|
+
when :first
|
62
|
+
@stack.unshift(entry)
|
63
|
+
when :last
|
64
|
+
@stack << entry
|
65
|
+
else
|
66
|
+
@stack << entry # Default append
|
67
|
+
end
|
68
|
+
|
69
|
+
@middleware_set.add(middleware_class)
|
70
|
+
@memoized_middleware_list = nil
|
71
|
+
end
|
72
|
+
|
73
|
+
# Validate MCP middleware ordering
|
74
|
+
#
|
75
|
+
# MCP middleware must be in security-optimal order:
|
76
|
+
# 1. RateLimitMiddleware (reject excessive requests early)
|
77
|
+
# 2. Auth middleware (validate credentials before parsing)
|
78
|
+
# 3. SchemaValidationMiddleware (expensive JSON schema validation last)
|
79
|
+
#
|
80
|
+
# @return [Array<String>] Warning messages if order is suboptimal
|
81
|
+
def validate_mcp_middleware_order
|
82
|
+
warnings = []
|
83
|
+
|
84
|
+
# PERFORMANCE NOTE: This implementation intentionally uses select + find_index
|
85
|
+
# rather than a single-pass approach. The filtered mcp_middlewares array is
|
86
|
+
# typically 0-3 items, making the performance difference unmeasurable.
|
87
|
+
# The current approach prioritizes readability over micro-optimization.
|
88
|
+
# Single-pass alternatives were considered but rejected as premature optimization.
|
89
|
+
mcp_middlewares = @stack.select do |entry|
|
90
|
+
[
|
91
|
+
Otto::MCP::RateLimitMiddleware,
|
92
|
+
Otto::MCP::Auth::TokenMiddleware,
|
93
|
+
Otto::MCP::SchemaValidationMiddleware,
|
94
|
+
].include?(entry[:middleware])
|
95
|
+
end
|
96
|
+
|
97
|
+
return warnings if mcp_middlewares.size < 2
|
98
|
+
|
99
|
+
# Find positions
|
100
|
+
rate_limit_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::RateLimitMiddleware }
|
101
|
+
auth_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::Auth::TokenMiddleware }
|
102
|
+
validation_pos = mcp_middlewares.find_index { |e| e[:middleware] == Otto::MCP::SchemaValidationMiddleware }
|
103
|
+
|
104
|
+
# Check optimal order: rate_limit < auth < validation
|
105
|
+
if rate_limit_pos && auth_pos && rate_limit_pos > auth_pos
|
106
|
+
warnings << '[MCP Middleware] RateLimitMiddleware should come before TokenMiddleware for optimal performance'
|
107
|
+
end
|
108
|
+
|
109
|
+
if auth_pos && validation_pos && auth_pos > validation_pos
|
110
|
+
warnings << '[MCP Middleware] TokenMiddleware should come before SchemaValidationMiddleware for optimal performance'
|
111
|
+
end
|
112
|
+
|
113
|
+
if rate_limit_pos && validation_pos && rate_limit_pos > validation_pos
|
114
|
+
warnings << '[MCP Middleware] RateLimitMiddleware should come before SchemaValidationMiddleware for optimal performance'
|
115
|
+
end
|
116
|
+
|
117
|
+
warnings
|
118
|
+
end
|
39
119
|
alias use add
|
40
120
|
alias << add
|
41
121
|
|
data/lib/otto/core/router.rb
CHANGED
@@ -12,7 +12,7 @@ class Otto
|
|
12
12
|
path = File.expand_path(path)
|
13
13
|
raise ArgumentError, "Bad path: #{path}" unless File.exist?(path)
|
14
14
|
|
15
|
-
raw = File.readlines(path).
|
15
|
+
raw = File.readlines(path).grep(/^\w/).collect(&:strip)
|
16
16
|
raw.each do |entry|
|
17
17
|
# Enhanced parsing: split only on first two whitespace boundaries
|
18
18
|
# This preserves parameters in the definition part
|
@@ -25,13 +25,9 @@ class Otto
|
|
25
25
|
|
26
26
|
# Check for MCP routes
|
27
27
|
if Otto::MCP::RouteParser.is_mcp_route?(definition)
|
28
|
-
raise '[MCP] MCP server not enabled' unless @mcp_server
|
29
|
-
|
30
28
|
handle_mcp_route(verb, path, definition)
|
31
29
|
next
|
32
30
|
elsif Otto::MCP::RouteParser.is_tool_route?(definition)
|
33
|
-
raise '[MCP] MCP server not enabled' unless @mcp_server
|
34
|
-
|
35
31
|
handle_tool_route(verb, path, definition)
|
36
32
|
next
|
37
33
|
end
|
@@ -46,7 +42,8 @@ class Otto
|
|
46
42
|
@routes_literal[route.verb] ||= {}
|
47
43
|
@routes_literal[route.verb][path_clean] = route
|
48
44
|
rescue StandardError => e
|
49
|
-
Otto.logger.error "
|
45
|
+
Otto.logger.error "Error for route #{path}: #{e.message}"
|
46
|
+
Otto.logger.debug e.backtrace.join("\n") if Otto.debug
|
50
47
|
end
|
51
48
|
self
|
52
49
|
end
|
@@ -164,6 +161,8 @@ class Otto
|
|
164
161
|
end
|
165
162
|
|
166
163
|
def handle_mcp_route(verb, path, definition)
|
164
|
+
raise '[MCP] MCP server not enabled' unless @mcp_server
|
165
|
+
|
167
166
|
route_info = Otto::MCP::RouteParser.parse_mcp_route(verb, path, definition)
|
168
167
|
@mcp_server.register_mcp_route(route_info)
|
169
168
|
Otto.logger.debug "[MCP] Registered resource route: #{definition}" if Otto.debug
|
@@ -172,6 +171,8 @@ class Otto
|
|
172
171
|
end
|
173
172
|
|
174
173
|
def handle_tool_route(verb, path, definition)
|
174
|
+
raise '[MCP] MCP server not enabled' unless @mcp_server
|
175
|
+
|
175
176
|
route_info = Otto::MCP::RouteParser.parse_tool_route(verb, path, definition)
|
176
177
|
@mcp_server.register_mcp_route(route_info)
|
177
178
|
Otto.logger.debug "[MCP] Registered tool route: #{definition}" if Otto.debug
|
@@ -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
@@ -5,26 +5,7 @@
|
|
5
5
|
class Otto
|
6
6
|
# Base helper methods providing core functionality for Otto applications
|
7
7
|
module BaseHelpers
|
8
|
-
#
|
9
|
-
#
|
10
|
-
# This method safely joins multiple path segments, handling
|
11
|
-
# duplicate slashes and ensuring proper path formatting.
|
12
|
-
# Includes the script name (mount point) as the first segment.
|
13
|
-
#
|
14
|
-
# @param paths [Array<String>] Path segments to join
|
15
|
-
# @return [String] Properly formatted path
|
16
|
-
#
|
17
|
-
# @example
|
18
|
-
# app_path('api', 'v1', 'users')
|
19
|
-
# # => "/myapp/api/v1/users"
|
20
|
-
#
|
21
|
-
# @example
|
22
|
-
# app_path(['admin', 'settings'])
|
23
|
-
# # => "/myapp/admin/settings"
|
24
|
-
def app_path(*paths)
|
25
|
-
paths = paths.flatten.compact
|
26
|
-
paths.unshift(env['SCRIPT_NAME']) if env['SCRIPT_NAME']
|
27
|
-
paths.join('/').gsub('//', '/')
|
28
|
-
end
|
8
|
+
# Keep only truly context-independent shared functionality here
|
9
|
+
# Methods requiring env access should be implemented in the specific helper modules
|
29
10
|
end
|
30
11
|
end
|
@@ -152,5 +152,27 @@ class Otto
|
|
152
152
|
headers['expires'] = 'Mon, 7 Nov 2011 00:00:00 UTC'
|
153
153
|
headers['pragma'] = 'no-cache'
|
154
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
|
155
177
|
end
|
156
178
|
end
|
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
# lib/otto/mcp/
|
3
|
+
# lib/otto/mcp/schema_validation.rb
|
4
4
|
|
5
5
|
require 'json'
|
6
6
|
|
@@ -67,7 +67,8 @@ class Otto
|
|
67
67
|
end
|
68
68
|
|
69
69
|
# Middleware for validating MCP protocol requests using JSON schema
|
70
|
-
|
70
|
+
# Validates JSON-RPC 2.0 structure and tool argument schemas
|
71
|
+
class SchemaValidationMiddleware
|
71
72
|
def initialize(app, _security_config = nil)
|
72
73
|
@app = app
|
73
74
|
@validator = Validator.new
|
data/lib/otto/mcp/server.rb
CHANGED
@@ -6,7 +6,7 @@ require_relative 'protocol'
|
|
6
6
|
require_relative 'registry'
|
7
7
|
require_relative 'route_parser'
|
8
8
|
require_relative 'auth/token'
|
9
|
-
require_relative '
|
9
|
+
require_relative 'schema_validation'
|
10
10
|
require_relative 'rate_limiting'
|
11
11
|
|
12
12
|
class Otto
|
@@ -53,30 +53,43 @@ class Otto
|
|
53
53
|
private
|
54
54
|
|
55
55
|
def configure_middleware(_options)
|
56
|
-
# Configure middleware in security-optimal order:
|
57
|
-
# 1. Rate limiting (reject excessive requests early)
|
58
|
-
# 2. Authentication (validate credentials before parsing)
|
59
|
-
# 3. Validation (expensive JSON schema validation last)
|
56
|
+
# Configure middleware in security-optimal order using explicit positioning:
|
57
|
+
# 1. Rate limiting (reject excessive requests early) - position: :first
|
58
|
+
# 2. Authentication (validate credentials before parsing) - default append
|
59
|
+
# 3. Validation (expensive JSON schema validation last) - position: :last
|
60
60
|
|
61
|
-
|
61
|
+
middleware = @otto_instance.instance_variable_get(:@middleware)
|
62
|
+
|
63
|
+
# Configure rate limiting first (explicit position for clarity)
|
62
64
|
if @enable_rate_limiting
|
63
|
-
|
64
|
-
|
65
|
+
middleware.add_with_position(
|
66
|
+
Otto::MCP::RateLimitMiddleware,
|
67
|
+
@otto_instance.security_config,
|
68
|
+
position: :first
|
69
|
+
)
|
70
|
+
Otto.logger.debug '[MCP] Rate limiting enabled (position: first)' if Otto.debug
|
65
71
|
end
|
66
72
|
|
67
|
-
# Configure authentication second
|
73
|
+
# Configure authentication second (default append order)
|
68
74
|
if @auth_tokens.any?
|
69
|
-
@auth
|
75
|
+
@auth = Otto::MCP::Auth::TokenAuth.new(@auth_tokens)
|
70
76
|
@otto_instance.security_config.mcp_auth = @auth
|
71
77
|
@otto_instance.use Otto::MCP::Auth::TokenMiddleware
|
72
78
|
Otto.logger.debug '[MCP] Token authentication enabled' if Otto.debug
|
73
79
|
end
|
74
80
|
|
75
|
-
# Configure validation last (
|
81
|
+
# Configure validation last (explicit position for clarity)
|
76
82
|
return unless @enable_validation
|
77
83
|
|
78
|
-
|
79
|
-
|
84
|
+
middleware.add_with_position(
|
85
|
+
Otto::MCP::SchemaValidationMiddleware,
|
86
|
+
position: :last
|
87
|
+
)
|
88
|
+
Otto.logger.debug '[MCP] Schema validation enabled (position: last)' if Otto.debug
|
89
|
+
|
90
|
+
# Validate middleware order (should pass with explicit positioning)
|
91
|
+
warnings = middleware.validate_mcp_middleware_order
|
92
|
+
warnings.each { |warning| Otto.logger.warn warning }
|
80
93
|
end
|
81
94
|
|
82
95
|
def add_mcp_endpoint_route
|
@@ -7,6 +7,12 @@ class Otto
|
|
7
7
|
# Handler for JSON responses
|
8
8
|
class JSONHandler < BaseHandler
|
9
9
|
def self.handle(result, response, context = {})
|
10
|
+
# If a redirect has already been set, don't override with JSON
|
11
|
+
# This allows controllers to conditionally redirect based on Accept header
|
12
|
+
if response.status&.between?(300, 399) && response['Location']
|
13
|
+
return
|
14
|
+
end
|
15
|
+
|
10
16
|
response['Content-Type'] = 'application/json'
|
11
17
|
|
12
18
|
# Determine the data to serialize
|
data/lib/otto/route.rb
CHANGED
@@ -45,10 +45,10 @@ class Otto
|
|
45
45
|
# "V2::Logic::AuthSession auth=authenticated response=redirect" (enhanced)
|
46
46
|
# @raise [ArgumentError] if definition format is invalid or class name is unsafe
|
47
47
|
def initialize(verb, path, definition)
|
48
|
-
|
48
|
+
pattern, keys = *compile(path)
|
49
49
|
|
50
50
|
# Create immutable route definition
|
51
|
-
@route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern:
|
51
|
+
@route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
|
52
52
|
|
53
53
|
# Resolve the class
|
54
54
|
@klass = safe_const_get(@route_definition.klass_name)
|
@@ -87,52 +87,6 @@ class Otto
|
|
87
87
|
@route_definition.options
|
88
88
|
end
|
89
89
|
|
90
|
-
private
|
91
|
-
|
92
|
-
# Safely resolve a class name using Object.const_get with security validations
|
93
|
-
# This replaces the previous eval() usage to prevent code injection attacks.
|
94
|
-
#
|
95
|
-
# Security features:
|
96
|
-
# - Validates class name format (must start with capital letter)
|
97
|
-
# - Prevents access to dangerous system classes
|
98
|
-
# - Blocks relative class references (starting with ::)
|
99
|
-
# - Provides clear error messages for debugging
|
100
|
-
#
|
101
|
-
# @param class_name [String] The class name to resolve
|
102
|
-
# @return [Class] The resolved class
|
103
|
-
# @raise [ArgumentError] if class name is invalid, forbidden, or not found
|
104
|
-
def safe_const_get(class_name)
|
105
|
-
# Validate class name format
|
106
|
-
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
107
|
-
raise ArgumentError, "Invalid class name format: #{class_name}"
|
108
|
-
end
|
109
|
-
|
110
|
-
# Prevent dangerous class names
|
111
|
-
forbidden_classes = %w[
|
112
|
-
Kernel Module Class Object BasicObject
|
113
|
-
File Dir IO Process System
|
114
|
-
Binding Proc Method UnboundMethod
|
115
|
-
Thread ThreadGroup Fiber
|
116
|
-
ObjectSpace GC
|
117
|
-
]
|
118
|
-
|
119
|
-
if forbidden_classes.include?(class_name) || class_name.start_with?('::')
|
120
|
-
raise ArgumentError, "Forbidden class name: #{class_name}"
|
121
|
-
end
|
122
|
-
|
123
|
-
begin
|
124
|
-
Object.const_get(class_name)
|
125
|
-
rescue NameError => e
|
126
|
-
raise ArgumentError, "Class not found: #{class_name} - #{e.message}"
|
127
|
-
end
|
128
|
-
end
|
129
|
-
|
130
|
-
public
|
131
|
-
|
132
|
-
def pattern_regexp
|
133
|
-
Regexp.new(@path.gsub('/*', '/.+'))
|
134
|
-
end
|
135
|
-
|
136
90
|
# Execute the route by calling the associated class method
|
137
91
|
#
|
138
92
|
# This method handles the complete request/response cycle with built-in security:
|
@@ -218,6 +172,48 @@ class Otto
|
|
218
172
|
|
219
173
|
private
|
220
174
|
|
175
|
+
# Safely resolve a class name using Object.const_get with security validations
|
176
|
+
# This replaces the previous eval() usage to prevent code injection attacks.
|
177
|
+
#
|
178
|
+
# Security features:
|
179
|
+
# - Validates class name format (must start with capital letter)
|
180
|
+
# - Prevents access to dangerous system classes
|
181
|
+
# - Blocks relative class references (starting with ::)
|
182
|
+
# - Provides clear error messages for debugging
|
183
|
+
#
|
184
|
+
# @param class_name [String] The class name to resolve
|
185
|
+
# @return [Class] The resolved class
|
186
|
+
# @raise [ArgumentError] if class name is invalid, forbidden, or not found
|
187
|
+
def safe_const_get(class_name)
|
188
|
+
# Validate class name format
|
189
|
+
unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
|
190
|
+
raise ArgumentError, "Invalid class name format: #{class_name}"
|
191
|
+
end
|
192
|
+
|
193
|
+
# Remove any leading :: then add exactly one
|
194
|
+
fq_class_name = "::#{class_name.sub(/^::+/, '')}"
|
195
|
+
|
196
|
+
# Prevent dangerous class names
|
197
|
+
forbidden_classes = %w[
|
198
|
+
Kernel Module Class Object BasicObject
|
199
|
+
File Dir IO Process System
|
200
|
+
Binding Proc Method UnboundMethod
|
201
|
+
Thread ThreadGroup Fiber
|
202
|
+
ObjectSpace GC
|
203
|
+
]
|
204
|
+
|
205
|
+
if forbidden_classes.include?(class_name) || class_name.start_with?('::')
|
206
|
+
raise ArgumentError, "Forbidden class name: #{class_name}"
|
207
|
+
end
|
208
|
+
|
209
|
+
begin
|
210
|
+
# Always guarantee exactly two leading colons
|
211
|
+
Object.const_get(fq_class_name)
|
212
|
+
rescue NameError => e
|
213
|
+
raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
|
214
|
+
end
|
215
|
+
end
|
216
|
+
|
221
217
|
def compile(path)
|
222
218
|
keys = []
|
223
219
|
|
@@ -13,16 +13,29 @@ class Otto
|
|
13
13
|
# @param otto_instance [Otto] The Otto instance for configuration access
|
14
14
|
# @return [BaseHandler] Appropriate handler for the route
|
15
15
|
def self.create_handler(route_definition, otto_instance = nil)
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
16
|
+
# Create base handler based on route kind
|
17
|
+
handler = case route_definition.kind
|
18
|
+
when :logic
|
19
|
+
LogicClassHandler.new(route_definition, otto_instance)
|
20
|
+
when :instance
|
21
|
+
InstanceMethodHandler.new(route_definition, otto_instance)
|
22
|
+
when :class
|
23
|
+
ClassMethodHandler.new(route_definition, otto_instance)
|
24
|
+
else
|
25
|
+
raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
|
26
|
+
end
|
27
|
+
|
28
|
+
# Wrap with auth enforcement if route has auth requirement
|
29
|
+
if route_definition.auth_requirement && otto_instance&.auth_config
|
30
|
+
require_relative '../security/authentication/route_auth_wrapper'
|
31
|
+
handler = Otto::Security::Authentication::RouteAuthWrapper.new(
|
32
|
+
handler,
|
33
|
+
route_definition,
|
34
|
+
otto_instance.auth_config
|
35
|
+
)
|
25
36
|
end
|
37
|
+
|
38
|
+
handler
|
26
39
|
end
|
27
40
|
end
|
28
41
|
end
|
@@ -2,7 +2,8 @@
|
|
2
2
|
|
3
3
|
require_relative 'strategy_result'
|
4
4
|
require_relative 'failure_result'
|
5
|
-
require_relative '
|
5
|
+
require_relative 'route_auth_wrapper'
|
6
|
+
require_relative 'strategies/noauth_strategy'
|
6
7
|
require_relative 'strategies/role_strategy'
|
7
8
|
require_relative 'strategies/permission_strategy'
|
8
9
|
|
@@ -16,10 +17,10 @@ class Otto
|
|
16
17
|
@security_config = security_config
|
17
18
|
@config = config
|
18
19
|
@strategies = config[:auth_strategies] || {}
|
19
|
-
@default_strategy = config[:default_auth_strategy] || '
|
20
|
+
@default_strategy = config[:default_auth_strategy] || 'noauth'
|
20
21
|
|
21
|
-
# Add default
|
22
|
-
@strategies['
|
22
|
+
# Add default noauth strategy if not provided
|
23
|
+
@strategies['noauth'] ||= Strategies::NoAuthStrategy.new
|
23
24
|
end
|
24
25
|
|
25
26
|
def call(env)
|
@@ -51,15 +52,10 @@ class Otto
|
|
51
52
|
# Perform authentication
|
52
53
|
strategy_result = strategy.authenticate(env, auth_requirement)
|
53
54
|
|
54
|
-
|
55
|
-
|
56
|
-
env['otto.strategy_result'] = strategy_result
|
57
|
-
env['otto.user'] = strategy_result.user # For convenience
|
58
|
-
env['otto.user_context'] = strategy_result.user_context # For convenience
|
59
|
-
@app.call(env)
|
60
|
-
else
|
55
|
+
# Check result type: FailureResult indicates auth failure, StrategyResult indicates success
|
56
|
+
if strategy_result.is_a?(Otto::Security::Authentication::FailureResult)
|
61
57
|
# Failure - create anonymous result with failure info
|
62
|
-
failure_reason = strategy_result
|
58
|
+
failure_reason = strategy_result.failure_reason || 'Authentication failed'
|
63
59
|
env['otto.strategy_result'] = Otto::Security::Authentication::StrategyResult.anonymous(
|
64
60
|
metadata: {
|
65
61
|
ip: env['REMOTE_ADDR'],
|
@@ -68,6 +64,23 @@ class Otto
|
|
68
64
|
}
|
69
65
|
)
|
70
66
|
auth_error_response(failure_reason)
|
67
|
+
else
|
68
|
+
# Success - store the strategy result directly
|
69
|
+
env['otto.strategy_result'] = strategy_result
|
70
|
+
|
71
|
+
# SESSION PERSISTENCE: This assignment is INTENTIONAL, not a merge operation.
|
72
|
+
# We must ensure env['rack.session'] and strategy_result.session reference
|
73
|
+
# the SAME object so that:
|
74
|
+
# 1. Logic classes write to strategy_result.session
|
75
|
+
# 2. Rack's session middleware persists env['rack.session']
|
76
|
+
# 3. Changes from (1) are included in (2)
|
77
|
+
#
|
78
|
+
# Using merge! instead would break this - the objects must be identical.
|
79
|
+
# See commit ed7fa0d for the bug this fixes.
|
80
|
+
env['rack.session'] = strategy_result.session if strategy_result.session
|
81
|
+
env['otto.user'] = strategy_result.user # For convenience
|
82
|
+
env['otto.user_context'] = strategy_result.user_context # For convenience
|
83
|
+
@app.call(env)
|
71
84
|
end
|
72
85
|
end
|
73
86
|
|
@@ -109,6 +122,10 @@ class Otto
|
|
109
122
|
}
|
110
123
|
|
111
124
|
# Add security headers if available from config hash or Otto instance
|
125
|
+
# NOTE: Extracting this to a method was considered but rejected.
|
126
|
+
# This logic appears only once and is clear in context. Extraction would
|
127
|
+
# add ~10 lines (method def + docs) for a 5-line single-use block without
|
128
|
+
# improving readability. Consider extracting if this pattern is duplicated.
|
112
129
|
if @config.is_a?(Hash) && @config[:security_headers]
|
113
130
|
headers.merge!(@config[:security_headers])
|
114
131
|
elsif @config.respond_to?(:security_config) && @config.security_config
|