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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +44 -5
  3. data/.github/workflows/claude-code-review.yml +53 -0
  4. data/.github/workflows/claude.yml +49 -0
  5. data/.gitignore +3 -0
  6. data/.rubocop.yml +24 -345
  7. data/CHANGELOG.rst +83 -0
  8. data/CLAUDE.md +56 -0
  9. data/Gemfile +21 -5
  10. data/Gemfile.lock +69 -31
  11. data/README.md +2 -0
  12. data/bin/rspec +16 -0
  13. data/changelog.d/20250911_235619_delano_next.rst +28 -0
  14. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +21 -0
  15. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +21 -0
  16. data/changelog.d/README.md +120 -0
  17. data/changelog.d/scriv.ini +5 -0
  18. data/docs/.gitignore +1 -0
  19. data/docs/migrating/v2.0.0-pre1.md +276 -0
  20. data/examples/.gitignore +1 -0
  21. data/examples/advanced_routes/README.md +33 -0
  22. data/examples/advanced_routes/app/controllers/handlers/async.rb +9 -0
  23. data/examples/advanced_routes/app/controllers/handlers/dynamic.rb +9 -0
  24. data/examples/advanced_routes/app/controllers/handlers/static.rb +9 -0
  25. data/examples/advanced_routes/app/controllers/modules/auth.rb +9 -0
  26. data/examples/advanced_routes/app/controllers/modules/transformer.rb +9 -0
  27. data/examples/advanced_routes/app/controllers/modules/validator.rb +9 -0
  28. data/examples/advanced_routes/app/controllers/routes_app.rb +232 -0
  29. data/examples/advanced_routes/app/controllers/v2/admin.rb +9 -0
  30. data/examples/advanced_routes/app/controllers/v2/config.rb +9 -0
  31. data/examples/advanced_routes/app/controllers/v2/settings.rb +9 -0
  32. data/examples/advanced_routes/app/logic/admin/logic/manager.rb +27 -0
  33. data/examples/advanced_routes/app/logic/admin/panel.rb +27 -0
  34. data/examples/advanced_routes/app/logic/analytics_processor.rb +25 -0
  35. data/examples/advanced_routes/app/logic/complex/business/handler.rb +27 -0
  36. data/examples/advanced_routes/app/logic/data_logic.rb +23 -0
  37. data/examples/advanced_routes/app/logic/data_processor.rb +25 -0
  38. data/examples/advanced_routes/app/logic/input_validator.rb +24 -0
  39. data/examples/advanced_routes/app/logic/nested/feature/logic.rb +27 -0
  40. data/examples/advanced_routes/app/logic/reports_generator.rb +27 -0
  41. data/examples/advanced_routes/app/logic/simple_logic.rb +25 -0
  42. data/examples/advanced_routes/app/logic/system/config/manager.rb +27 -0
  43. data/examples/advanced_routes/app/logic/test_logic.rb +23 -0
  44. data/examples/advanced_routes/app/logic/transform_logic.rb +23 -0
  45. data/examples/advanced_routes/app/logic/upload_logic.rb +23 -0
  46. data/examples/advanced_routes/app/logic/v2/logic/dashboard.rb +27 -0
  47. data/examples/advanced_routes/app/logic/v2/logic/processor.rb +27 -0
  48. data/examples/advanced_routes/app.rb +33 -0
  49. data/examples/advanced_routes/config.rb +23 -0
  50. data/examples/advanced_routes/config.ru +7 -0
  51. data/examples/advanced_routes/puma.rb +20 -0
  52. data/examples/advanced_routes/routes +167 -0
  53. data/examples/advanced_routes/run.rb +39 -0
  54. data/examples/advanced_routes/test.rb +58 -0
  55. data/examples/authentication_strategies/README.md +32 -0
  56. data/examples/authentication_strategies/app/auth.rb +68 -0
  57. data/examples/authentication_strategies/app/controllers/auth_controller.rb +29 -0
  58. data/examples/authentication_strategies/app/controllers/main_controller.rb +28 -0
  59. data/examples/authentication_strategies/config.ru +24 -0
  60. data/examples/authentication_strategies/routes +37 -0
  61. data/examples/basic/README.md +29 -0
  62. data/examples/basic/app.rb +7 -35
  63. data/examples/basic/routes +0 -9
  64. data/examples/mcp_demo/README.md +87 -0
  65. data/examples/mcp_demo/app.rb +51 -0
  66. data/examples/mcp_demo/config.ru +17 -0
  67. data/examples/mcp_demo/routes +9 -0
  68. data/examples/security_features/README.md +46 -0
  69. data/examples/security_features/app.rb +23 -24
  70. data/examples/security_features/config.ru +8 -10
  71. data/lib/otto/core/configuration.rb +167 -0
  72. data/lib/otto/core/error_handler.rb +86 -0
  73. data/lib/otto/core/file_safety.rb +61 -0
  74. data/lib/otto/core/middleware_stack.rb +157 -0
  75. data/lib/otto/core/router.rb +183 -0
  76. data/lib/otto/core/uri_generator.rb +44 -0
  77. data/lib/otto/design_system.rb +7 -5
  78. data/lib/otto/helpers/base.rb +3 -0
  79. data/lib/otto/helpers/request.rb +10 -8
  80. data/lib/otto/helpers/response.rb +5 -4
  81. data/lib/otto/helpers/validation.rb +85 -0
  82. data/lib/otto/mcp/auth/token.rb +77 -0
  83. data/lib/otto/mcp/protocol.rb +164 -0
  84. data/lib/otto/mcp/rate_limiting.rb +155 -0
  85. data/lib/otto/mcp/registry.rb +100 -0
  86. data/lib/otto/mcp/route_parser.rb +77 -0
  87. data/lib/otto/mcp/server.rb +206 -0
  88. data/lib/otto/mcp/validation.rb +123 -0
  89. data/lib/otto/response_handlers/auto.rb +39 -0
  90. data/lib/otto/response_handlers/base.rb +16 -0
  91. data/lib/otto/response_handlers/default.rb +16 -0
  92. data/lib/otto/response_handlers/factory.rb +39 -0
  93. data/lib/otto/response_handlers/json.rb +28 -0
  94. data/lib/otto/response_handlers/redirect.rb +25 -0
  95. data/lib/otto/response_handlers/view.rb +24 -0
  96. data/lib/otto/response_handlers.rb +9 -135
  97. data/lib/otto/route.rb +9 -9
  98. data/lib/otto/route_definition.rb +30 -33
  99. data/lib/otto/route_handlers/base.rb +121 -0
  100. data/lib/otto/route_handlers/class_method.rb +89 -0
  101. data/lib/otto/route_handlers/factory.rb +29 -0
  102. data/lib/otto/route_handlers/instance_method.rb +69 -0
  103. data/lib/otto/route_handlers/lambda.rb +59 -0
  104. data/lib/otto/route_handlers/logic_class.rb +93 -0
  105. data/lib/otto/route_handlers.rb +10 -376
  106. data/lib/otto/security/authentication/auth_strategy.rb +44 -0
  107. data/lib/otto/security/authentication/authentication_middleware.rb +123 -0
  108. data/lib/otto/security/authentication/failure_result.rb +36 -0
  109. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +40 -0
  110. data/lib/otto/security/authentication/strategies/permission_strategy.rb +47 -0
  111. data/lib/otto/security/authentication/strategies/public_strategy.rb +19 -0
  112. data/lib/otto/security/authentication/strategies/role_strategy.rb +57 -0
  113. data/lib/otto/security/authentication/strategies/session_strategy.rb +41 -0
  114. data/lib/otto/security/authentication/strategy_result.rb +223 -0
  115. data/lib/otto/security/authentication.rb +28 -282
  116. data/lib/otto/security/config.rb +15 -11
  117. data/lib/otto/security/configurator.rb +219 -0
  118. data/lib/otto/security/csrf.rb +8 -143
  119. data/lib/otto/security/middleware/csrf_middleware.rb +151 -0
  120. data/lib/otto/security/middleware/rate_limit_middleware.rb +38 -0
  121. data/lib/otto/security/middleware/validation_middleware.rb +252 -0
  122. data/lib/otto/security/rate_limiter.rb +86 -0
  123. data/lib/otto/security/rate_limiting.rb +16 -0
  124. data/lib/otto/security/validator.rb +8 -292
  125. data/lib/otto/static.rb +3 -0
  126. data/lib/otto/utils.rb +14 -0
  127. data/lib/otto/version.rb +3 -1
  128. data/lib/otto.rb +184 -414
  129. data/otto.gemspec +11 -6
  130. metadata +134 -25
  131. data/examples/dynamic_pages/app.rb +0 -115
  132. data/examples/dynamic_pages/config.ru +0 -30
  133. data/examples/dynamic_pages/routes +0 -21
  134. data/examples/helpers_demo/app.rb +0 -244
  135. data/examples/helpers_demo/config.ru +0 -26
  136. data/examples/helpers_demo/routes +0 -7
@@ -1,7 +1,8 @@
1
- # examples/security_features/app.rb (Updated with Design System)
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
- otto_alert('error', 'Validation Error', 'Message cannot be empty.')
110
- else
111
- <<~HTML
112
- #{otto_alert('success', 'Feedback Received', 'Thank you for your feedback!')}
113
-
114
- #{otto_card('Your Message') do
115
- otto_code_block(safe_message, 'text')
116
- end}
117
- HTML
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 => ex
120
- content = otto_alert('error', 'Security Validation Failed', ex.message)
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
- uploaded_file[:filename]
139
+ uploaded_file[:filename]
139
140
  rescue StandardError
140
- uploaded_file.original_filename
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
- sanitize_filename(filename)
148
- else
149
- File.basename(filename.to_s).gsub(/[^\w\-_\.]/, '_')
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 => ex
177
- content = otto_alert('error', 'File Validation Failed', ex.message)
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 => ex
225
- content = otto_alert('error', 'Profile Validation Failed', ex.message)
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
- # Enable CSRF protection for POST, PUT, DELETE requests
18
- csrf_protection: true,
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 # 5MB limit
52
- app.security_config.max_param_depth = 10 # Limit parameter nesting
53
- app.security_config.max_param_keys = 50 # Limit parameters per request
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
- 'content-security-policy' => "default-src 'self'; style-src 'self'; script-src 'self'; object-src 'none'",
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