otto 1.5.0 → 2.0.0.pre1
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 +44 -5
- data/.github/workflows/claude-code-review.yml +53 -0
- data/.github/workflows/claude.yml +49 -0
- data/.gitignore +3 -0
- data/.rubocop.yml +24 -345
- data/CHANGELOG.rst +83 -0
- data/CLAUDE.md +56 -0
- data/Gemfile +21 -5
- data/Gemfile.lock +69 -31
- data/README.md +2 -0
- data/bin/rspec +16 -0
- data/changelog.d/20250911_235619_delano_next.rst +28 -0
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
- data/changelog.d/README.md +120 -0
- data/changelog.d/scriv.ini +5 -0
- data/docs/.gitignore +1 -0
- data/docs/migrating/v2.0.0-pre1.md +276 -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 +51 -0
- data/examples/mcp_demo/config.ru +17 -0
- data/examples/mcp_demo/routes +9 -0
- 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 +157 -0
- data/lib/otto/core/router.rb +183 -0
- data/lib/otto/core/uri_generator.rb +44 -0
- data/lib/otto/design_system.rb +7 -5
- data/lib/otto/helpers/base.rb +3 -0
- data/lib/otto/helpers/request.rb +10 -8
- data/lib/otto/helpers/response.rb +5 -4
- data/lib/otto/helpers/validation.rb +85 -0
- data/lib/otto/mcp/auth/token.rb +77 -0
- data/lib/otto/mcp/protocol.rb +164 -0
- data/lib/otto/mcp/rate_limiting.rb +155 -0
- data/lib/otto/mcp/registry.rb +100 -0
- data/lib/otto/mcp/route_parser.rb +77 -0
- data/lib/otto/mcp/server.rb +206 -0
- data/lib/otto/mcp/validation.rb +123 -0
- 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 +28 -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 +9 -9
- data/lib/otto/route_definition.rb +30 -33
- 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 +29 -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 -376
- data/lib/otto/security/authentication/auth_strategy.rb +44 -0
- data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
- data/lib/otto/security/authentication/failure_result.rb +36 -0
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
- data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -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 +223 -0
- data/lib/otto/security/authentication.rb +28 -282
- data/lib/otto/security/config.rb +15 -11
- 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 +38 -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 +16 -0
- data/lib/otto/security/validator.rb +8 -292
- 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 +184 -414
- data/otto.gemspec +11 -6
- metadata +134 -25
- 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
@@ -1,7 +1,8 @@
|
|
1
|
-
# examples/security_features/app.rb
|
1
|
+
# examples/security_features/app.rb
|
2
2
|
|
3
3
|
require_relative '../../lib/otto/design_system'
|
4
4
|
|
5
|
+
# Example application demonstrating Otto's security features including CSRF protection
|
5
6
|
class SecureApp
|
6
7
|
include Otto::DesignSystem
|
7
8
|
include Otto::Security::CSRFHelpers
|
@@ -106,18 +107,18 @@ class SecureApp
|
|
106
107
|
end
|
107
108
|
|
108
109
|
content = if safe_message.empty?
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
110
|
+
otto_alert('error', 'Validation Error', 'Message cannot be empty.')
|
111
|
+
else
|
112
|
+
<<~HTML
|
113
|
+
#{otto_alert('success', 'Feedback Received', 'Thank you for your feedback!')}
|
114
|
+
|
115
|
+
#{otto_card('Your Message') do
|
116
|
+
otto_code_block(safe_message, 'text')
|
117
|
+
end}
|
118
|
+
HTML
|
118
119
|
end
|
119
|
-
rescue Otto::Security::ValidationError =>
|
120
|
-
content = otto_alert('error', 'Security Validation Failed',
|
120
|
+
rescue Otto::Security::ValidationError => e
|
121
|
+
content = otto_alert('error', 'Security Validation Failed', e.message)
|
121
122
|
rescue StandardError
|
122
123
|
content = otto_alert('error', 'Processing Error', 'An error occurred processing your request.')
|
123
124
|
end
|
@@ -135,18 +136,18 @@ class SecureApp
|
|
135
136
|
else
|
136
137
|
begin
|
137
138
|
filename = begin
|
138
|
-
|
139
|
+
uploaded_file[:filename]
|
139
140
|
rescue StandardError
|
140
|
-
|
141
|
+
uploaded_file.original_filename
|
141
142
|
end
|
142
143
|
rescue StandardError
|
143
144
|
'unknown'
|
144
145
|
end
|
145
146
|
|
146
147
|
safe_filename = if respond_to?(:sanitize_filename)
|
147
|
-
|
148
|
-
|
149
|
-
|
148
|
+
sanitize_filename(filename)
|
149
|
+
else
|
150
|
+
File.basename(filename.to_s).gsub(/[^\w\-_.]/, '_')
|
150
151
|
end
|
151
152
|
|
152
153
|
file_info = {
|
@@ -173,8 +174,8 @@ class SecureApp
|
|
173
174
|
</div>
|
174
175
|
HTML
|
175
176
|
end
|
176
|
-
rescue Otto::Security::ValidationError =>
|
177
|
-
content = otto_alert('error', 'File Validation Failed',
|
177
|
+
rescue Otto::Security::ValidationError => e
|
178
|
+
content = otto_alert('error', 'File Validation Failed', e.message)
|
178
179
|
rescue StandardError
|
179
180
|
content = otto_alert('error', 'Upload Error', 'An error occurred during file upload.')
|
180
181
|
end
|
@@ -199,9 +200,7 @@ class SecureApp
|
|
199
200
|
safe_bio = bio.to_s.strip[0..499]
|
200
201
|
end
|
201
202
|
|
202
|
-
unless safe_email.match?(/\A[^@\s]+@[^@\s]+\z/)
|
203
|
-
raise Otto::Security::ValidationError, 'Invalid email format'
|
204
|
-
end
|
203
|
+
raise Otto::Security::ValidationError, 'Invalid email format' unless safe_email.match?(/\A[^@\s]+@[^@\s]+\z/)
|
205
204
|
|
206
205
|
profile_data = {
|
207
206
|
'Name' => safe_name,
|
@@ -221,8 +220,8 @@ class SecureApp
|
|
221
220
|
profile_html
|
222
221
|
end}
|
223
222
|
HTML
|
224
|
-
rescue Otto::Security::ValidationError =>
|
225
|
-
content = otto_alert('error', 'Profile Validation Failed',
|
223
|
+
rescue Otto::Security::ValidationError => e
|
224
|
+
content = otto_alert('error', 'Profile Validation Failed', e.message)
|
226
225
|
rescue StandardError
|
227
226
|
content = otto_alert('error', 'Update Error', 'An error occurred updating your profile.')
|
228
227
|
end
|
@@ -14,8 +14,8 @@ require_relative 'app'
|
|
14
14
|
|
15
15
|
# Create Otto app with security features enabled
|
16
16
|
app = Otto.new('./routes', {
|
17
|
-
|
18
|
-
|
17
|
+
# Enable CSRF protection for POST, PUT, DELETE requests
|
18
|
+
csrf_protection: true,
|
19
19
|
|
20
20
|
# Enable input validation and sanitization
|
21
21
|
request_validation: true,
|
@@ -44,13 +44,12 @@ app = Otto.new('./routes', {
|
|
44
44
|
'strict-transport-security' => 'max-age=31536000; includeSubDomains',
|
45
45
|
'x-frame-options' => 'DENY',
|
46
46
|
},
|
47
|
-
}
|
48
|
-
)
|
47
|
+
})
|
49
48
|
|
50
49
|
# Optional: Configure additional security settings
|
51
|
-
app.security_config.max_request_size = 5 * 1024 * 1024
|
52
|
-
app.security_config.max_param_depth = 10
|
53
|
-
app.security_config.max_param_keys = 50
|
50
|
+
app.security_config.max_request_size = 5 * 1024 * 1024 # 5MB limit
|
51
|
+
app.security_config.max_param_depth = 10 # Limit parameter nesting
|
52
|
+
app.security_config.max_param_keys = 50 # Limit parameters per request
|
54
53
|
|
55
54
|
# Optional: Add static file serving with security
|
56
55
|
app.option[:public] = public_path
|
@@ -62,10 +61,9 @@ if ENV['RACK_ENV'] == 'production'
|
|
62
61
|
|
63
62
|
# More restrictive CSP for production
|
64
63
|
app.set_security_headers({
|
65
|
-
|
64
|
+
'content-security-policy' => "default-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'",
|
66
65
|
'strict-transport-security' => 'max-age=63072000; includeSubDomains; preload',
|
67
|
-
|
68
|
-
)
|
66
|
+
})
|
69
67
|
else
|
70
68
|
# Development-specific settings
|
71
69
|
puts '🔒 Security features enabled:'
|
@@ -0,0 +1,167 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/core/configuration.rb
|
4
|
+
|
5
|
+
require_relative '../security/csrf'
|
6
|
+
require_relative '../security/validator'
|
7
|
+
require_relative '../security/authentication'
|
8
|
+
require_relative '../security/rate_limiting'
|
9
|
+
require_relative '../mcp/server'
|
10
|
+
|
11
|
+
class Otto
|
12
|
+
module Core
|
13
|
+
# Configuration module providing locale and application configuration methods
|
14
|
+
module Configuration
|
15
|
+
def configure_locale(opts)
|
16
|
+
# Start with global configuration
|
17
|
+
global_config = self.class.global_config
|
18
|
+
@locale_config = nil
|
19
|
+
|
20
|
+
# Check if we have any locale configuration from any source
|
21
|
+
has_global_locale = global_config && (global_config[:available_locales] || global_config[:default_locale])
|
22
|
+
has_direct_options = opts[:available_locales] || opts[:default_locale]
|
23
|
+
has_legacy_config = opts[:locale_config]
|
24
|
+
|
25
|
+
# Only create locale_config if we have configuration from somewhere
|
26
|
+
return unless has_global_locale || has_direct_options || has_legacy_config
|
27
|
+
|
28
|
+
@locale_config = {}
|
29
|
+
|
30
|
+
# Apply global configuration first
|
31
|
+
if global_config && global_config[:available_locales]
|
32
|
+
@locale_config[:available_locales] =
|
33
|
+
global_config[:available_locales]
|
34
|
+
end
|
35
|
+
if global_config && global_config[:default_locale]
|
36
|
+
@locale_config[:default_locale] =
|
37
|
+
global_config[:default_locale]
|
38
|
+
end
|
39
|
+
|
40
|
+
# Apply direct instance options (these override global config)
|
41
|
+
@locale_config[:available_locales] = opts[:available_locales] if opts[:available_locales]
|
42
|
+
@locale_config[:default_locale] = opts[:default_locale] if opts[:default_locale]
|
43
|
+
|
44
|
+
# Legacy support: Configure locale if provided in initialization options via locale_config hash
|
45
|
+
return unless opts[:locale_config]
|
46
|
+
|
47
|
+
locale_opts = opts[:locale_config]
|
48
|
+
if locale_opts[:available_locales] || locale_opts[:available]
|
49
|
+
@locale_config[:available_locales] =
|
50
|
+
locale_opts[:available_locales] || locale_opts[:available]
|
51
|
+
end
|
52
|
+
return unless locale_opts[:default_locale] || locale_opts[:default]
|
53
|
+
|
54
|
+
@locale_config[:default_locale] =
|
55
|
+
locale_opts[:default_locale] || locale_opts[:default]
|
56
|
+
end
|
57
|
+
|
58
|
+
def configure_security(opts)
|
59
|
+
# Enable CSRF protection if requested
|
60
|
+
enable_csrf_protection! if opts[:csrf_protection]
|
61
|
+
|
62
|
+
# Enable request validation if requested
|
63
|
+
enable_request_validation! if opts[:request_validation]
|
64
|
+
|
65
|
+
# Enable rate limiting if requested
|
66
|
+
if opts[:rate_limiting]
|
67
|
+
rate_limiting_opts = opts[:rate_limiting].is_a?(Hash) ? opts[:rate_limiting] : {}
|
68
|
+
enable_rate_limiting!(rate_limiting_opts)
|
69
|
+
end
|
70
|
+
|
71
|
+
# Add trusted proxies if provided
|
72
|
+
Array(opts[:trusted_proxies]).each { |proxy| add_trusted_proxy(proxy) } if opts[:trusted_proxies]
|
73
|
+
|
74
|
+
# Set custom security headers
|
75
|
+
return unless opts[:security_headers]
|
76
|
+
|
77
|
+
set_security_headers(opts[:security_headers])
|
78
|
+
end
|
79
|
+
|
80
|
+
def configure_authentication(opts)
|
81
|
+
# Update existing @auth_config rather than creating a new one
|
82
|
+
# to maintain synchronization with the configurator
|
83
|
+
@auth_config[:auth_strategies] = opts[:auth_strategies] if opts[:auth_strategies]
|
84
|
+
@auth_config[:default_auth_strategy] = opts[:default_auth_strategy] if opts[:default_auth_strategy]
|
85
|
+
|
86
|
+
# Enable authentication middleware if strategies are configured
|
87
|
+
return unless opts[:auth_strategies] && !opts[:auth_strategies].empty?
|
88
|
+
|
89
|
+
enable_authentication!
|
90
|
+
end
|
91
|
+
|
92
|
+
def configure_mcp(opts)
|
93
|
+
@mcp_server = nil
|
94
|
+
|
95
|
+
# Enable MCP if requested in options
|
96
|
+
return unless opts[:mcp_enabled] || opts[:mcp_http] || opts[:mcp_stdio]
|
97
|
+
|
98
|
+
@mcp_server = Otto::MCP::Server.new(self)
|
99
|
+
|
100
|
+
mcp_options = {}
|
101
|
+
mcp_options[:http_endpoint] = opts[:mcp_endpoint] if opts[:mcp_endpoint]
|
102
|
+
|
103
|
+
return unless opts[:mcp_http] != false # Default to true unless explicitly disabled
|
104
|
+
|
105
|
+
@mcp_server.enable!(mcp_options)
|
106
|
+
end
|
107
|
+
|
108
|
+
# Configure locale settings for the application
|
109
|
+
#
|
110
|
+
# @param available_locales [Hash] Hash of available locales (e.g., { 'en' => 'English', 'es' => 'Spanish' })
|
111
|
+
# @param default_locale [String] Default locale to use as fallback
|
112
|
+
# @example
|
113
|
+
# otto.configure(
|
114
|
+
# available_locales: { 'en' => 'English', 'es' => 'Spanish', 'fr' => 'French' },
|
115
|
+
# default_locale: 'en'
|
116
|
+
# )
|
117
|
+
def configure(available_locales: nil, default_locale: nil)
|
118
|
+
@locale_config ||= {}
|
119
|
+
@locale_config[:available_locales] = available_locales if available_locales
|
120
|
+
@locale_config[:default_locale] = default_locale if default_locale
|
121
|
+
end
|
122
|
+
|
123
|
+
# Configure rate limiting settings.
|
124
|
+
#
|
125
|
+
# @param config [Hash] Rate limiting configuration
|
126
|
+
# @option config [Integer] :requests_per_minute Maximum requests per minute per IP
|
127
|
+
# @option config [Hash] :custom_rules Hash of custom rate limiting rules
|
128
|
+
# @option config [Object] :cache_store Custom cache store for rate limiting
|
129
|
+
# @example
|
130
|
+
# otto.configure_rate_limiting({
|
131
|
+
# requests_per_minute: 50,
|
132
|
+
# custom_rules: {
|
133
|
+
# 'api_calls' => { limit: 30, period: 60, condition: ->(req) { req.path.start_with?('/api') }}
|
134
|
+
# }
|
135
|
+
# })
|
136
|
+
def configure_rate_limiting(config)
|
137
|
+
@security_config.rate_limiting_config.merge!(config)
|
138
|
+
end
|
139
|
+
|
140
|
+
# Configure authentication strategies for route-level access control.
|
141
|
+
#
|
142
|
+
# @param strategies [Hash] Hash mapping strategy names to strategy instances
|
143
|
+
# @param default_strategy [String] Default strategy to use when none specified
|
144
|
+
# @example
|
145
|
+
# otto.configure_auth_strategies({
|
146
|
+
# 'publicly' => Otto::Security::Authentication::Strategies::PublicStrategy.new,
|
147
|
+
# 'authenticated' => Otto::Security::Authentication::Strategies::SessionStrategy.new(session_key: 'user_id'),
|
148
|
+
# 'role:admin' => Otto::Security::Authentication::Strategies::RoleStrategy.new(['admin']),
|
149
|
+
# 'api_key' => Otto::Security::Authentication::Strategies::APIKeyStrategy.new(api_keys: ['secret123'])
|
150
|
+
# })
|
151
|
+
def configure_auth_strategies(strategies, default_strategy: 'publicly')
|
152
|
+
# Update existing @auth_config rather than creating a new one
|
153
|
+
@auth_config[:auth_strategies] = strategies
|
154
|
+
@auth_config[:default_auth_strategy] = default_strategy
|
155
|
+
|
156
|
+
enable_authentication! unless strategies.empty?
|
157
|
+
end
|
158
|
+
|
159
|
+
private
|
160
|
+
|
161
|
+
def middleware_enabled?(middleware_class)
|
162
|
+
# Only check the new middleware stack as the single source of truth
|
163
|
+
@middleware && @middleware.includes?(middleware_class)
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/core/error_handler.rb
|
4
|
+
|
5
|
+
require 'securerandom'
|
6
|
+
require 'json'
|
7
|
+
require 'rack/request'
|
8
|
+
|
9
|
+
class Otto
|
10
|
+
module Core
|
11
|
+
# Error handling module providing secure error reporting and logging functionality
|
12
|
+
module ErrorHandler
|
13
|
+
def handle_error(error, env)
|
14
|
+
# Log error details internally but don't expose them
|
15
|
+
error_id = SecureRandom.hex(8)
|
16
|
+
Otto.logger.error "[#{error_id}] #{error.class}: #{error.message}"
|
17
|
+
Otto.logger.debug "[#{error_id}] Backtrace: #{error.backtrace.join("\n")}" if Otto.debug
|
18
|
+
|
19
|
+
# Parse request for content negotiation
|
20
|
+
begin
|
21
|
+
Rack::Request.new(env)
|
22
|
+
rescue StandardError
|
23
|
+
nil
|
24
|
+
end
|
25
|
+
literal_routes = @routes_literal[:GET] || {}
|
26
|
+
|
27
|
+
# Try custom 500 route first
|
28
|
+
if found_route = literal_routes['/500']
|
29
|
+
begin
|
30
|
+
env['otto.error_id'] = error_id
|
31
|
+
return found_route.call(env)
|
32
|
+
rescue StandardError => e
|
33
|
+
Otto.logger.error "[#{error_id}] Error in custom error handler: #{e.message}"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Content negotiation for built-in error response
|
38
|
+
accept_header = env['HTTP_ACCEPT'].to_s
|
39
|
+
return json_error_response(error_id) if accept_header.include?('application/json')
|
40
|
+
|
41
|
+
# Fallback to built-in error response
|
42
|
+
@server_error || secure_error_response(error_id)
|
43
|
+
end
|
44
|
+
|
45
|
+
private
|
46
|
+
|
47
|
+
def secure_error_response(error_id)
|
48
|
+
body = if Otto.env?(:dev, :development)
|
49
|
+
"Server error (ID: #{error_id}). Check logs for details."
|
50
|
+
else
|
51
|
+
'An error occurred. Please try again later.'
|
52
|
+
end
|
53
|
+
|
54
|
+
headers = {
|
55
|
+
'content-type' => 'text/plain',
|
56
|
+
'content-length' => body.bytesize.to_s,
|
57
|
+
}.merge(@security_config.security_headers)
|
58
|
+
|
59
|
+
[500, headers, [body]]
|
60
|
+
end
|
61
|
+
|
62
|
+
def json_error_response(error_id)
|
63
|
+
error_data = if Otto.env?(:dev, :development)
|
64
|
+
{
|
65
|
+
error: 'Internal Server Error',
|
66
|
+
message: 'Server error occurred. Check logs for details.',
|
67
|
+
error_id: error_id,
|
68
|
+
}
|
69
|
+
else
|
70
|
+
{
|
71
|
+
error: 'Internal Server Error',
|
72
|
+
message: 'An error occurred. Please try again later.',
|
73
|
+
}
|
74
|
+
end
|
75
|
+
|
76
|
+
body = JSON.generate(error_data)
|
77
|
+
headers = {
|
78
|
+
'content-type' => 'application/json',
|
79
|
+
'content-length' => body.bytesize.to_s,
|
80
|
+
}.merge(@security_config.security_headers)
|
81
|
+
|
82
|
+
[500, headers, [body]]
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/core/file_safety.rb
|
4
|
+
|
5
|
+
class Otto
|
6
|
+
module Core
|
7
|
+
# File safety module providing secure file access validation and path traversal protection
|
8
|
+
module FileSafety
|
9
|
+
def safe_file?(path)
|
10
|
+
return false if option[:public].nil? || option[:public].empty?
|
11
|
+
return false if path.nil? || path.empty?
|
12
|
+
|
13
|
+
# Normalize and resolve the public directory path
|
14
|
+
public_dir = File.expand_path(option[:public])
|
15
|
+
return false unless File.directory?(public_dir)
|
16
|
+
|
17
|
+
# Clean the requested path - remove null bytes and normalize
|
18
|
+
clean_path = path.delete("\0").strip
|
19
|
+
return false if clean_path.empty?
|
20
|
+
|
21
|
+
# Join and expand to get the full resolved path
|
22
|
+
requested_path = File.expand_path(File.join(public_dir, clean_path))
|
23
|
+
|
24
|
+
# Ensure the resolved path is within the public directory (prevents path traversal)
|
25
|
+
return false unless requested_path.start_with?(public_dir + File::SEPARATOR)
|
26
|
+
|
27
|
+
# Check file exists, is readable, and is not a directory
|
28
|
+
File.exist?(requested_path) &&
|
29
|
+
File.readable?(requested_path) &&
|
30
|
+
!File.directory?(requested_path) &&
|
31
|
+
(File.owned?(requested_path) || File.grpowned?(requested_path))
|
32
|
+
end
|
33
|
+
|
34
|
+
def safe_dir?(path)
|
35
|
+
return false if path.nil? || path.empty?
|
36
|
+
|
37
|
+
# Clean and expand the path
|
38
|
+
clean_path = path.delete("\0").strip
|
39
|
+
return false if clean_path.empty?
|
40
|
+
|
41
|
+
expanded_path = File.expand_path(clean_path)
|
42
|
+
|
43
|
+
# Check directory exists, is readable, and has proper ownership
|
44
|
+
File.directory?(expanded_path) &&
|
45
|
+
File.readable?(expanded_path) &&
|
46
|
+
(File.owned?(expanded_path) || File.grpowned?(expanded_path))
|
47
|
+
end
|
48
|
+
|
49
|
+
def add_static_path(path)
|
50
|
+
return unless safe_file?(path)
|
51
|
+
|
52
|
+
base_path = File.split(path).first
|
53
|
+
# Files in the root directory can refer to themselves
|
54
|
+
base_path = path if base_path == '/'
|
55
|
+
File.join(option[:public], base_path)
|
56
|
+
Otto.logger.debug "new static route: #{base_path} (#{path})" if Otto.debug
|
57
|
+
routes_static[:GET][base_path] = base_path
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# lib/otto/core/middleware_stack.rb
|
4
|
+
|
5
|
+
class Otto
|
6
|
+
module Core
|
7
|
+
# Enhanced middleware stack management for Otto framework.
|
8
|
+
# Provides better middleware registration, introspection capabilities,
|
9
|
+
# and improved execution chain management.
|
10
|
+
class MiddlewareStack
|
11
|
+
include Enumerable
|
12
|
+
|
13
|
+
def initialize
|
14
|
+
@stack = []
|
15
|
+
@middleware_set = Set.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# Enhanced middleware registration with argument uniqueness and immutability check
|
19
|
+
def add(middleware_class, *args, **options)
|
20
|
+
# Prevent modifications to frozen configurations
|
21
|
+
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
22
|
+
|
23
|
+
# Check if an identical middleware configuration already exists
|
24
|
+
existing_entry = @stack.find do |entry|
|
25
|
+
entry[:middleware] == middleware_class &&
|
26
|
+
entry[:args] == args &&
|
27
|
+
entry[:options] == options
|
28
|
+
end
|
29
|
+
|
30
|
+
# Only add if no identical middleware configuration exists
|
31
|
+
return if existing_entry
|
32
|
+
|
33
|
+
entry = { middleware: middleware_class, args: args, options: options }
|
34
|
+
@stack << entry
|
35
|
+
@middleware_set.add(middleware_class)
|
36
|
+
# Invalidate memoized middleware list
|
37
|
+
@memoized_middleware_list = nil
|
38
|
+
end
|
39
|
+
alias use add
|
40
|
+
alias << add
|
41
|
+
|
42
|
+
# Remove middleware
|
43
|
+
def remove(middleware_class)
|
44
|
+
# Prevent modifications to frozen configurations
|
45
|
+
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
46
|
+
|
47
|
+
matches = @stack.reject! { |entry| entry[:middleware] == middleware_class }
|
48
|
+
|
49
|
+
# Update middleware set if any matching entries were found
|
50
|
+
return unless matches
|
51
|
+
|
52
|
+
# Rebuild the set of unique middleware classes
|
53
|
+
@middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
|
54
|
+
# Invalidate memoized middleware list
|
55
|
+
@memoized_middleware_list = nil
|
56
|
+
end
|
57
|
+
|
58
|
+
# Check if middleware is registered - now O(1) using Set
|
59
|
+
def includes?(middleware_class)
|
60
|
+
@middleware_set.include?(middleware_class)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Clear all middleware
|
64
|
+
def clear!
|
65
|
+
# Prevent modifications to frozen configurations
|
66
|
+
raise FrozenError, 'Cannot modify frozen middleware stack' if frozen?
|
67
|
+
|
68
|
+
@stack.clear
|
69
|
+
@middleware_set.clear
|
70
|
+
# Invalidate memoized middleware list
|
71
|
+
@memoized_middleware_list = nil
|
72
|
+
end
|
73
|
+
|
74
|
+
# Enumerable support
|
75
|
+
def each(&)
|
76
|
+
@stack.each(&)
|
77
|
+
end
|
78
|
+
|
79
|
+
# Build Rack application with middleware chain
|
80
|
+
def build_app(base_app, security_config = nil)
|
81
|
+
@stack.reduce(base_app) do |app, entry|
|
82
|
+
middleware = entry[:middleware]
|
83
|
+
args = entry[:args]
|
84
|
+
options = entry[:options]
|
85
|
+
|
86
|
+
if middleware.respond_to?(:new)
|
87
|
+
# Inject security_config for security middleware, placing it before custom args
|
88
|
+
if security_config && middleware_needs_config?(middleware)
|
89
|
+
middleware.new(app, security_config, *args, **options)
|
90
|
+
else
|
91
|
+
middleware.new(app, *args, **options)
|
92
|
+
end
|
93
|
+
else
|
94
|
+
# Proc-based middleware
|
95
|
+
middleware.call(app)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Cached middleware list to reduce array creation
|
101
|
+
def middleware_list
|
102
|
+
# Memoize the result to avoid repeated array creation
|
103
|
+
@memoized_middleware_list ||= @stack.map { |entry| entry[:middleware] }
|
104
|
+
end
|
105
|
+
|
106
|
+
# Detailed introspection
|
107
|
+
def middleware_details
|
108
|
+
@stack.map do |entry|
|
109
|
+
{
|
110
|
+
middleware: entry[:middleware],
|
111
|
+
args: entry[:args],
|
112
|
+
options: entry[:options],
|
113
|
+
}
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
# Statistics
|
118
|
+
def size
|
119
|
+
@stack.size
|
120
|
+
end
|
121
|
+
|
122
|
+
def empty?
|
123
|
+
@stack.empty?
|
124
|
+
end
|
125
|
+
|
126
|
+
# Count occurrences of a specific middleware class
|
127
|
+
def count(middleware_class)
|
128
|
+
@stack.count { |entry| entry[:middleware] == middleware_class }
|
129
|
+
end
|
130
|
+
|
131
|
+
# NOTE: The includes? method is defined earlier for O(1) lookup using a Set
|
132
|
+
|
133
|
+
# Legacy compatibility methods for existing Otto interface
|
134
|
+
def reverse_each(&)
|
135
|
+
@stack.reverse_each(&)
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def middleware_needs_config?(middleware_class)
|
141
|
+
# Include all Otto security middleware that can accept security_config
|
142
|
+
# Support both new namespaced classes and backward compatibility aliases
|
143
|
+
[
|
144
|
+
Otto::Security::Middleware::CSRFMiddleware,
|
145
|
+
Otto::Security::Middleware::ValidationMiddleware,
|
146
|
+
Otto::Security::Middleware::RateLimitMiddleware,
|
147
|
+
Otto::Security::Authentication::AuthenticationMiddleware,
|
148
|
+
# Backward compatibility aliases
|
149
|
+
Otto::Security::CSRFMiddleware,
|
150
|
+
Otto::Security::ValidationMiddleware,
|
151
|
+
Otto::Security::RateLimitMiddleware,
|
152
|
+
Otto::Security::AuthenticationMiddleware,
|
153
|
+
].include?(middleware_class)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|