otto 2.0.0.pre2 → 2.0.0.pre7
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 +1 -3
- data/.github/workflows/claude-code-review.yml +29 -13
- data/.github/workflows/code-smells.yml +146 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +90 -0
- data/CLAUDE.md +116 -45
- data/Gemfile +5 -2
- data/Gemfile.lock +70 -24
- data/README.md +49 -1
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
- data/docs/.gitignore +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/authentication_strategies/config.ru +0 -1
- data/examples/backtrace_sanitization_demo.rb +86 -0
- data/examples/basic/README.md +61 -10
- data/examples/error_handler_registration.rb +136 -0
- data/examples/logging_improvements.rb +76 -0
- data/examples/mcp_demo/README.md +187 -27
- data/examples/security_features/README.md +249 -30
- data/examples/simple_geo_resolver.rb +107 -0
- data/lib/otto/core/configuration.rb +90 -45
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +25 -18
- data/lib/otto/core/router.rb +62 -9
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +10 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +65 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +85 -2
- data/lib/otto/helpers/response.rb +5 -5
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +6 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/locale/middleware.rb +160 -0
- data/lib/otto/locale.rb +10 -0
- data/lib/otto/logging_helpers.rb +273 -0
- data/lib/otto/mcp/auth/token.rb +2 -2
- data/lib/otto/mcp/protocol.rb +2 -2
- data/lib/otto/mcp/rate_limiting.rb +2 -2
- data/lib/otto/mcp/registry.rb +2 -2
- data/lib/otto/mcp/route_parser.rb +2 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp/server.rb +2 -2
- data/lib/otto/mcp.rb +5 -0
- data/lib/otto/privacy/config.rb +201 -0
- data/lib/otto/privacy/geo_resolver.rb +285 -0
- data/lib/otto/privacy/ip_privacy.rb +177 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
- data/lib/otto/privacy.rb +31 -0
- data/lib/otto/response_handlers/auto.rb +2 -0
- data/lib/otto/response_handlers/base.rb +2 -0
- data/lib/otto/response_handlers/default.rb +2 -0
- data/lib/otto/response_handlers/factory.rb +2 -0
- data/lib/otto/response_handlers/json.rb +2 -0
- data/lib/otto/response_handlers/redirect.rb +2 -0
- data/lib/otto/response_handlers/view.rb +2 -0
- data/lib/otto/response_handlers.rb +2 -2
- data/lib/otto/route.rb +4 -4
- data/lib/otto/route_definition.rb +42 -15
- data/lib/otto/route_handlers/base.rb +2 -1
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +18 -16
- data/lib/otto/route_handlers/instance_method.rb +8 -5
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +25 -8
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
- data/lib/otto/security/authentication/auth_strategy.rb +13 -6
- data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategy_result.rb +6 -5
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +53 -9
- data/lib/otto/security/configurator.rb +17 -15
- data/lib/otto/security/csrf.rb +2 -2
- data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +231 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
- data/lib/otto/security/middleware/validation_middleware.rb +15 -0
- data/lib/otto/security/rate_limiter.rb +2 -2
- data/lib/otto/security/rate_limiting.rb +2 -2
- data/lib/otto/security/validator.rb +2 -2
- data/lib/otto/security.rb +12 -0
- data/lib/otto/static.rb +2 -2
- data/lib/otto/utils.rb +27 -2
- data/lib/otto/version.rb +3 -3
- data/lib/otto.rb +344 -89
- data/otto.gemspec +9 -2
- metadata +72 -8
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
|
@@ -1,58 +1,44 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/core/configuration.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require_relative '../security/csrf'
|
|
6
6
|
require_relative '../security/validator'
|
|
7
7
|
require_relative '../security/authentication'
|
|
8
8
|
require_relative '../security/rate_limiting'
|
|
9
9
|
require_relative '../mcp/server'
|
|
10
|
+
require_relative 'freezable'
|
|
10
11
|
|
|
11
12
|
class Otto
|
|
12
13
|
module Core
|
|
13
14
|
# Configuration module providing locale and application configuration methods
|
|
14
15
|
module Configuration
|
|
15
|
-
|
|
16
|
-
# Start with global configuration
|
|
17
|
-
global_config = self.class.global_config
|
|
18
|
-
@locale_config = nil
|
|
16
|
+
include Otto::Core::Freezable
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
def configure_locale(opts)
|
|
19
|
+
# Check if we have any locale configuration
|
|
22
20
|
has_direct_options = opts[:available_locales] || opts[:default_locale]
|
|
23
21
|
has_legacy_config = opts[:locale_config]
|
|
24
22
|
|
|
25
|
-
# Only create locale_config if we have configuration
|
|
26
|
-
return unless
|
|
23
|
+
# Only create locale_config if we have configuration
|
|
24
|
+
return unless has_direct_options || has_legacy_config
|
|
27
25
|
|
|
28
|
-
|
|
26
|
+
# Initialize with direct options
|
|
27
|
+
available_locales = opts[:available_locales]
|
|
28
|
+
default_locale = opts[:default_locale]
|
|
29
29
|
|
|
30
|
-
#
|
|
31
|
-
if
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if global_config && global_config[:default_locale]
|
|
36
|
-
@locale_config[:default_locale] =
|
|
37
|
-
global_config[:default_locale]
|
|
30
|
+
# Legacy support: Configure locale if provided via locale_config hash
|
|
31
|
+
if opts[:locale_config]
|
|
32
|
+
locale_opts = opts[:locale_config]
|
|
33
|
+
available_locales ||= locale_opts[:available_locales] || locale_opts[:available]
|
|
34
|
+
default_locale ||= locale_opts[:default_locale] || locale_opts[:default]
|
|
38
35
|
end
|
|
39
36
|
|
|
40
|
-
#
|
|
41
|
-
@locale_config
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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]
|
|
37
|
+
# Create Otto::Locale::Config instance
|
|
38
|
+
@locale_config = Otto::Locale::Config.new(
|
|
39
|
+
available_locales: available_locales,
|
|
40
|
+
default_locale: default_locale
|
|
41
|
+
)
|
|
56
42
|
end
|
|
57
43
|
|
|
58
44
|
def configure_security(opts)
|
|
@@ -83,10 +69,7 @@ class Otto
|
|
|
83
69
|
@auth_config[:auth_strategies] = opts[:auth_strategies] if opts[:auth_strategies]
|
|
84
70
|
@auth_config[:default_auth_strategy] = opts[:default_auth_strategy] if opts[:default_auth_strategy]
|
|
85
71
|
|
|
86
|
-
#
|
|
87
|
-
return unless opts[:auth_strategies] && !opts[:auth_strategies].empty?
|
|
88
|
-
|
|
89
|
-
enable_authentication!
|
|
72
|
+
# No-op: authentication strategies are configured via @auth_config above
|
|
90
73
|
end
|
|
91
74
|
|
|
92
75
|
def configure_mcp(opts)
|
|
@@ -115,9 +98,14 @@ class Otto
|
|
|
115
98
|
# default_locale: 'en'
|
|
116
99
|
# )
|
|
117
100
|
def configure(available_locales: nil, default_locale: nil)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
101
|
+
ensure_not_frozen!
|
|
102
|
+
|
|
103
|
+
# Initialize locale_config if not already set
|
|
104
|
+
@locale_config ||= Otto::Locale::Config.new
|
|
105
|
+
|
|
106
|
+
# Update configuration
|
|
107
|
+
@locale_config.available_locales = available_locales if available_locales
|
|
108
|
+
@locale_config.default_locale = default_locale if default_locale
|
|
121
109
|
end
|
|
122
110
|
|
|
123
111
|
# Configure rate limiting settings.
|
|
@@ -134,6 +122,7 @@ class Otto
|
|
|
134
122
|
# }
|
|
135
123
|
# })
|
|
136
124
|
def configure_rate_limiting(config)
|
|
125
|
+
ensure_not_frozen!
|
|
137
126
|
@security_config.rate_limiting_config.merge!(config)
|
|
138
127
|
end
|
|
139
128
|
|
|
@@ -149,18 +138,74 @@ class Otto
|
|
|
149
138
|
# 'api_key' => Otto::Security::Authentication::Strategies::APIKeyStrategy.new(api_keys: ['secret123'])
|
|
150
139
|
# })
|
|
151
140
|
def configure_auth_strategies(strategies, default_strategy: 'noauth')
|
|
141
|
+
ensure_not_frozen!
|
|
152
142
|
# Update existing @auth_config rather than creating a new one
|
|
153
143
|
@auth_config[:auth_strategies] = strategies
|
|
154
144
|
@auth_config[:default_auth_strategy] = default_strategy
|
|
145
|
+
end
|
|
155
146
|
|
|
156
|
-
|
|
147
|
+
# Freeze the application configuration to prevent runtime modifications.
|
|
148
|
+
# Called automatically at the end of initialization to ensure immutability.
|
|
149
|
+
#
|
|
150
|
+
# This prevents security-critical configuration from being modified after
|
|
151
|
+
# the application begins handling requests. Uses deep freezing to prevent
|
|
152
|
+
# both direct modification and modification through nested structures.
|
|
153
|
+
#
|
|
154
|
+
# @raise [RuntimeError] if configuration is already frozen
|
|
155
|
+
# @return [self]
|
|
156
|
+
def freeze_configuration!
|
|
157
|
+
if frozen_configuration?
|
|
158
|
+
Otto.structured_log(:debug, 'Configuration already frozen', { status: 'skipped' }) if Otto.debug
|
|
159
|
+
return self
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
start_time = Otto::Utils.now_in_μs
|
|
163
|
+
|
|
164
|
+
# Deep freeze configuration objects with memoization support
|
|
165
|
+
@security_config.deep_freeze! if @security_config.respond_to?(:deep_freeze!)
|
|
166
|
+
@locale_config.deep_freeze! if @locale_config.respond_to?(:deep_freeze!)
|
|
167
|
+
@middleware.deep_freeze! if @middleware.respond_to?(:deep_freeze!)
|
|
168
|
+
|
|
169
|
+
# Deep freeze configuration hashes (recursively freezes nested structures)
|
|
170
|
+
deep_freeze_value(@auth_config) if @auth_config
|
|
171
|
+
deep_freeze_value(@option) if @option
|
|
172
|
+
|
|
173
|
+
# Deep freeze route structures (prevent modification of nested hashes/arrays)
|
|
174
|
+
deep_freeze_value(@routes) if @routes
|
|
175
|
+
deep_freeze_value(@routes_literal) if @routes_literal
|
|
176
|
+
deep_freeze_value(@routes_static) if @routes_static
|
|
177
|
+
deep_freeze_value(@route_definitions) if @route_definitions
|
|
178
|
+
|
|
179
|
+
@configuration_frozen = true
|
|
180
|
+
|
|
181
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
182
|
+
frozen_objects = %w[security_config locale_config middleware auth_config option routes]
|
|
183
|
+
Otto.structured_log(:info, 'Freezing completed',
|
|
184
|
+
{
|
|
185
|
+
duration: duration,
|
|
186
|
+
frozen_objects: frozen_objects.join(','),
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
self
|
|
157
190
|
end
|
|
158
191
|
|
|
159
|
-
|
|
192
|
+
# Check if configuration is frozen
|
|
193
|
+
#
|
|
194
|
+
# @return [Boolean] true if configuration is frozen
|
|
195
|
+
def frozen_configuration?
|
|
196
|
+
@configuration_frozen == true
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Ensure configuration is not frozen before allowing mutations
|
|
200
|
+
#
|
|
201
|
+
# @raise [FrozenError] if configuration is frozen
|
|
202
|
+
def ensure_not_frozen!
|
|
203
|
+
raise FrozenError, 'Cannot modify frozen configuration' if frozen_configuration?
|
|
204
|
+
end
|
|
160
205
|
|
|
161
206
|
def middleware_enabled?(middleware_class)
|
|
162
207
|
# Only check the new middleware stack as the single source of truth
|
|
163
|
-
@middleware
|
|
208
|
+
@middleware&.includes?(middleware_class)
|
|
164
209
|
end
|
|
165
210
|
end
|
|
166
211
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto/core/error_handler.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'json'
|
|
@@ -11,10 +11,30 @@ class Otto
|
|
|
11
11
|
# Error handling module providing secure error reporting and logging functionality
|
|
12
12
|
module ErrorHandler
|
|
13
13
|
def handle_error(error, env)
|
|
14
|
+
# Check if this is a registered expected error
|
|
15
|
+
if handler_config = @error_handlers[error.class.name]
|
|
16
|
+
return handle_expected_error(error, env, handler_config)
|
|
17
|
+
end
|
|
18
|
+
|
|
14
19
|
# Log error details internally but don't expose them
|
|
15
20
|
error_id = SecureRandom.hex(8)
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
|
|
22
|
+
# Base context pattern: create once, reuse for correlation
|
|
23
|
+
base_context = Otto::LoggingHelpers.request_context(env)
|
|
24
|
+
|
|
25
|
+
# Include handler context if available (set by route handlers)
|
|
26
|
+
log_context = base_context.merge(
|
|
27
|
+
error: error.message,
|
|
28
|
+
error_class: error.class.name,
|
|
29
|
+
error_id: error_id
|
|
30
|
+
)
|
|
31
|
+
log_context[:handler] = env['otto.handler'] if env['otto.handler']
|
|
32
|
+
log_context[:duration] = env['otto.handler_duration'] if env['otto.handler_duration']
|
|
33
|
+
|
|
34
|
+
Otto.structured_log(:error, 'Unhandled error in request', log_context)
|
|
35
|
+
|
|
36
|
+
Otto::LoggingHelpers.log_backtrace(error,
|
|
37
|
+
base_context.merge(error_id: error_id))
|
|
18
38
|
|
|
19
39
|
# Parse request for content negotiation
|
|
20
40
|
begin
|
|
@@ -30,7 +50,21 @@ class Otto
|
|
|
30
50
|
env['otto.error_id'] = error_id
|
|
31
51
|
return found_route.call(env)
|
|
32
52
|
rescue StandardError => e
|
|
33
|
-
|
|
53
|
+
# When the custom error handler itself fails, generate a new error ID
|
|
54
|
+
# to distinguish it from the original error, but link them.
|
|
55
|
+
custom_handler_error_id = SecureRandom.hex(8)
|
|
56
|
+
base_context = Otto::LoggingHelpers.request_context(env)
|
|
57
|
+
|
|
58
|
+
Otto.structured_log(:error, 'Error in custom error handler',
|
|
59
|
+
base_context.merge(
|
|
60
|
+
error: e.message,
|
|
61
|
+
error_class: e.class.name,
|
|
62
|
+
error_id: custom_handler_error_id,
|
|
63
|
+
original_error_id: error_id # Link to original error
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
Otto::LoggingHelpers.log_backtrace(e,
|
|
67
|
+
base_context.merge(error_id: custom_handler_error_id, original_error_id: error_id))
|
|
34
68
|
end
|
|
35
69
|
end
|
|
36
70
|
|
|
@@ -44,6 +78,102 @@ class Otto
|
|
|
44
78
|
|
|
45
79
|
private
|
|
46
80
|
|
|
81
|
+
# Handle expected business logic errors with custom status codes and logging
|
|
82
|
+
#
|
|
83
|
+
# @param error [Exception] The expected error to handle
|
|
84
|
+
# @param env [Hash] Rack environment hash
|
|
85
|
+
# @param handler_config [Hash] Configuration from error_handlers registry
|
|
86
|
+
# @return [Array] Rack response tuple [status, headers, body]
|
|
87
|
+
def handle_expected_error(error, env, handler_config)
|
|
88
|
+
# Generate error ID for correlation (even for expected errors)
|
|
89
|
+
error_id = SecureRandom.hex(8)
|
|
90
|
+
|
|
91
|
+
# Base context pattern: create once, reuse for correlation
|
|
92
|
+
base_context = Otto::LoggingHelpers.request_context(env)
|
|
93
|
+
|
|
94
|
+
# Include handler context if available
|
|
95
|
+
log_context = base_context.merge(
|
|
96
|
+
error: error.message,
|
|
97
|
+
error_class: error.class.name,
|
|
98
|
+
error_id: error_id,
|
|
99
|
+
expected: true # Mark as expected error
|
|
100
|
+
)
|
|
101
|
+
log_context[:handler] = env['otto.handler'] if env['otto.handler']
|
|
102
|
+
log_context[:duration] = env['otto.handler_duration'] if env['otto.handler_duration']
|
|
103
|
+
|
|
104
|
+
# Log at configured level (info/warn instead of error)
|
|
105
|
+
log_level = handler_config[:log_level] || :info
|
|
106
|
+
Otto.structured_log(log_level, 'Expected error in request', log_context)
|
|
107
|
+
|
|
108
|
+
# Build response body
|
|
109
|
+
response_body = if handler_config[:handler]
|
|
110
|
+
# Use custom handler block if provided
|
|
111
|
+
begin
|
|
112
|
+
req = Rack::Request.new(env)
|
|
113
|
+
result = handler_config[:handler].call(error, req)
|
|
114
|
+
|
|
115
|
+
# Validate that custom handler returned a Hash
|
|
116
|
+
unless result.is_a?(Hash)
|
|
117
|
+
base_context = Otto::LoggingHelpers.request_context(env)
|
|
118
|
+
Otto.structured_log(:warn, 'Custom error handler returned non-hash value',
|
|
119
|
+
base_context.merge(
|
|
120
|
+
error_class: error.class.name,
|
|
121
|
+
handler_result_class: result.class.name,
|
|
122
|
+
error_id: error_id
|
|
123
|
+
))
|
|
124
|
+
result = { error: error.class.name.split('::').last, message: error.message }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
result
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
# If custom handler fails, fall back to default
|
|
130
|
+
base_context = Otto::LoggingHelpers.request_context(env)
|
|
131
|
+
Otto.structured_log(:warn, 'Error in custom error handler',
|
|
132
|
+
base_context.merge(
|
|
133
|
+
error: e.message,
|
|
134
|
+
error_class: e.class.name,
|
|
135
|
+
original_error_class: error.class.name,
|
|
136
|
+
error_id: error_id
|
|
137
|
+
))
|
|
138
|
+
{ error: error.class.name.split('::').last, message: error.message }
|
|
139
|
+
end
|
|
140
|
+
else
|
|
141
|
+
# Default response body
|
|
142
|
+
{ error: error.class.name.split('::').last, message: error.message }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Add error_id in development mode
|
|
146
|
+
response_body[:error_id] = error_id if Otto.env?(:dev, :development)
|
|
147
|
+
|
|
148
|
+
# Content negotiation
|
|
149
|
+
accept_header = env['HTTP_ACCEPT'].to_s
|
|
150
|
+
status = handler_config[:status] || 500
|
|
151
|
+
|
|
152
|
+
if accept_header.include?('application/json')
|
|
153
|
+
body = JSON.generate(response_body)
|
|
154
|
+
headers = {
|
|
155
|
+
'content-type' => 'application/json',
|
|
156
|
+
'content-length' => body.bytesize.to_s
|
|
157
|
+
}.merge(@security_config.security_headers)
|
|
158
|
+
|
|
159
|
+
[status, headers, [body]]
|
|
160
|
+
else
|
|
161
|
+
# Plain text response
|
|
162
|
+
body = if Otto.env?(:dev, :development)
|
|
163
|
+
"#{response_body[:error]}: #{response_body[:message]} (ID: #{error_id})"
|
|
164
|
+
else
|
|
165
|
+
"#{response_body[:error]}: #{response_body[:message]}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
headers = {
|
|
169
|
+
'content-type' => 'text/plain',
|
|
170
|
+
'content-length' => body.bytesize.to_s
|
|
171
|
+
}.merge(@security_config.security_headers)
|
|
172
|
+
|
|
173
|
+
[status, headers, [body]]
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
47
177
|
def secure_error_response(error_id)
|
|
48
178
|
body = if Otto.env?(:dev, :development)
|
|
49
179
|
"Server error (ID: #{error_id}). Check logs for details."
|
|
@@ -62,13 +192,13 @@ class Otto
|
|
|
62
192
|
def json_error_response(error_id)
|
|
63
193
|
error_data = if Otto.env?(:dev, :development)
|
|
64
194
|
{
|
|
65
|
-
|
|
66
|
-
|
|
195
|
+
error: 'Internal Server Error',
|
|
196
|
+
message: 'Server error occurred. Check logs for details.',
|
|
67
197
|
error_id: error_id,
|
|
68
198
|
}
|
|
69
199
|
else
|
|
70
200
|
{
|
|
71
|
-
|
|
201
|
+
error: 'Internal Server Error',
|
|
72
202
|
message: 'An error occurred. Please try again later.',
|
|
73
203
|
}
|
|
74
204
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# lib/otto/core/freezable.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'set'
|
|
6
|
+
|
|
7
|
+
class Otto
|
|
8
|
+
module Core
|
|
9
|
+
# Provides deep freezing capability for configuration objects
|
|
10
|
+
#
|
|
11
|
+
# This module enables objects to be deeply frozen, preventing any
|
|
12
|
+
# modifications to the object itself and all its nested structures.
|
|
13
|
+
# This is critical for security as it prevents runtime tampering with
|
|
14
|
+
# security configurations.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# class MyConfig
|
|
18
|
+
# include Otto::Core::Freezable
|
|
19
|
+
#
|
|
20
|
+
# def initialize
|
|
21
|
+
# @settings = { security: { enabled: true } }
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# config = MyConfig.new
|
|
26
|
+
# config.deep_freeze!
|
|
27
|
+
# # Now config and all nested hashes/arrays are frozen
|
|
28
|
+
#
|
|
29
|
+
module Freezable
|
|
30
|
+
# Deeply freeze this object and all its instance variables
|
|
31
|
+
#
|
|
32
|
+
# This method recursively freezes all nested structures including:
|
|
33
|
+
# - Hashes (both keys and values)
|
|
34
|
+
# - Arrays (and all elements)
|
|
35
|
+
# - Sets
|
|
36
|
+
# - Other freezable objects
|
|
37
|
+
#
|
|
38
|
+
# NOTE: This method is idempotent and safe to call multiple times.
|
|
39
|
+
#
|
|
40
|
+
# @return [self] The frozen object
|
|
41
|
+
def deep_freeze!
|
|
42
|
+
return self if frozen?
|
|
43
|
+
|
|
44
|
+
freeze_instance_variables!
|
|
45
|
+
freeze
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Freeze all instance variables recursively
|
|
52
|
+
def freeze_instance_variables!
|
|
53
|
+
instance_variables.each do |var|
|
|
54
|
+
value = instance_variable_get(var)
|
|
55
|
+
deep_freeze_value(value)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Recursively freeze a value based on its type
|
|
60
|
+
#
|
|
61
|
+
# @param value [Object] Value to freeze
|
|
62
|
+
# @return [void]
|
|
63
|
+
def deep_freeze_value(value)
|
|
64
|
+
case value
|
|
65
|
+
when Hash
|
|
66
|
+
# Freeze hash keys and values, then freeze the hash itself
|
|
67
|
+
value.each do |k, v|
|
|
68
|
+
k.freeze unless k.frozen?
|
|
69
|
+
deep_freeze_value(v)
|
|
70
|
+
end
|
|
71
|
+
value.freeze
|
|
72
|
+
when Array
|
|
73
|
+
# Freeze all array elements, then freeze the array
|
|
74
|
+
value.each { |item| deep_freeze_value(item) }
|
|
75
|
+
value.freeze
|
|
76
|
+
when Set
|
|
77
|
+
# Sets are immutable once frozen
|
|
78
|
+
value.freeze
|
|
79
|
+
when String, Symbol, Numeric, TrueClass, FalseClass, NilClass
|
|
80
|
+
# These types are either immutable or already frozen
|
|
81
|
+
value.freeze if value.respond_to?(:freeze) && !value.frozen?
|
|
82
|
+
else
|
|
83
|
+
# For other objects, recursively freeze if they support it, otherwise shallow freeze.
|
|
84
|
+
if value.respond_to?(:deep_freeze!)
|
|
85
|
+
value.deep_freeze!
|
|
86
|
+
elsif value.respond_to?(:freeze) && !value.frozen?
|
|
87
|
+
value.freeze
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
# lib/otto/core/middleware_stack.rb
|
|
2
|
+
#
|
|
1
3
|
# frozen_string_literal: true
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
require_relative 'freezable'
|
|
4
6
|
|
|
5
7
|
class Otto
|
|
6
8
|
module Core
|
|
@@ -9,10 +11,18 @@ class Otto
|
|
|
9
11
|
# and improved execution chain management.
|
|
10
12
|
class MiddlewareStack
|
|
11
13
|
include Enumerable
|
|
14
|
+
include Otto::Core::Freezable
|
|
12
15
|
|
|
13
16
|
def initialize
|
|
14
17
|
@stack = []
|
|
15
18
|
@middleware_set = Set.new
|
|
19
|
+
@on_change_callback = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Set a callback to be invoked when the middleware stack changes
|
|
23
|
+
# @param callback [Proc] A callable object (e.g., method or lambda)
|
|
24
|
+
def on_change(&callback)
|
|
25
|
+
@on_change_callback = callback
|
|
16
26
|
end
|
|
17
27
|
|
|
18
28
|
# Enhanced middleware registration with argument uniqueness and immutability check
|
|
@@ -33,8 +43,8 @@ class Otto
|
|
|
33
43
|
entry = { middleware: middleware_class, args: args, options: options }
|
|
34
44
|
@stack << entry
|
|
35
45
|
@middleware_set.add(middleware_class)
|
|
36
|
-
#
|
|
37
|
-
@
|
|
46
|
+
# Notify of change
|
|
47
|
+
@on_change_callback&.call
|
|
38
48
|
end
|
|
39
49
|
|
|
40
50
|
# Add middleware with position hint for optimal ordering
|
|
@@ -67,7 +77,8 @@ class Otto
|
|
|
67
77
|
end
|
|
68
78
|
|
|
69
79
|
@middleware_set.add(middleware_class)
|
|
70
|
-
|
|
80
|
+
# Notify of change
|
|
81
|
+
@on_change_callback&.call
|
|
71
82
|
end
|
|
72
83
|
|
|
73
84
|
# Validate MCP middleware ordering
|
|
@@ -131,8 +142,8 @@ class Otto
|
|
|
131
142
|
|
|
132
143
|
# Rebuild the set of unique middleware classes
|
|
133
144
|
@middleware_set = Set.new(@stack.map { |entry| entry[:middleware] })
|
|
134
|
-
#
|
|
135
|
-
@
|
|
145
|
+
# Notify of change
|
|
146
|
+
@on_change_callback&.call
|
|
136
147
|
end
|
|
137
148
|
|
|
138
149
|
# Check if middleware is registered - now O(1) using Set
|
|
@@ -147,8 +158,8 @@ class Otto
|
|
|
147
158
|
|
|
148
159
|
@stack.clear
|
|
149
160
|
@middleware_set.clear
|
|
150
|
-
#
|
|
151
|
-
@
|
|
161
|
+
# Notify of change
|
|
162
|
+
@on_change_callback&.call
|
|
152
163
|
end
|
|
153
164
|
|
|
154
165
|
# Enumerable support
|
|
@@ -157,7 +168,7 @@ class Otto
|
|
|
157
168
|
end
|
|
158
169
|
|
|
159
170
|
# Build Rack application with middleware chain
|
|
160
|
-
def
|
|
171
|
+
def wrap(base_app, security_config = nil)
|
|
161
172
|
@stack.reduce(base_app) do |app, entry|
|
|
162
173
|
middleware = entry[:middleware]
|
|
163
174
|
args = entry[:args]
|
|
@@ -177,10 +188,9 @@ class Otto
|
|
|
177
188
|
end
|
|
178
189
|
end
|
|
179
190
|
|
|
180
|
-
#
|
|
191
|
+
# Returns list of middleware classes in order
|
|
181
192
|
def middleware_list
|
|
182
|
-
|
|
183
|
-
@memoized_middleware_list ||= @stack.map { |entry| entry[:middleware] }
|
|
193
|
+
@stack.map { |entry| entry[:middleware] }
|
|
184
194
|
end
|
|
185
195
|
|
|
186
196
|
# Detailed introspection
|
|
@@ -215,6 +225,8 @@ class Otto
|
|
|
215
225
|
@stack.reverse_each(&)
|
|
216
226
|
end
|
|
217
227
|
|
|
228
|
+
|
|
229
|
+
|
|
218
230
|
private
|
|
219
231
|
|
|
220
232
|
def middleware_needs_config?(middleware_class)
|
|
@@ -224,12 +236,7 @@ class Otto
|
|
|
224
236
|
Otto::Security::Middleware::CSRFMiddleware,
|
|
225
237
|
Otto::Security::Middleware::ValidationMiddleware,
|
|
226
238
|
Otto::Security::Middleware::RateLimitMiddleware,
|
|
227
|
-
Otto::Security::
|
|
228
|
-
# Backward compatibility aliases
|
|
229
|
-
Otto::Security::CSRFMiddleware,
|
|
230
|
-
Otto::Security::ValidationMiddleware,
|
|
231
|
-
Otto::Security::RateLimitMiddleware,
|
|
232
|
-
Otto::Security::AuthenticationMiddleware,
|
|
239
|
+
Otto::Security::Middleware::IPPrivacyMiddleware,
|
|
233
240
|
].include?(middleware_class)
|
|
234
241
|
end
|
|
235
242
|
end
|