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
data/lib/otto.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
1
|
# lib/otto.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
require 'json'
|
|
6
6
|
require 'logger'
|
|
@@ -11,29 +11,20 @@ require 'rack/request'
|
|
|
11
11
|
require 'rack/response'
|
|
12
12
|
require 'rack/utils'
|
|
13
13
|
|
|
14
|
-
require_relative 'otto/security/authentication/strategy_result'
|
|
15
14
|
require_relative 'otto/route_definition'
|
|
16
15
|
require_relative 'otto/route'
|
|
17
16
|
require_relative 'otto/static'
|
|
18
|
-
require_relative 'otto/helpers
|
|
19
|
-
require_relative 'otto/helpers/response'
|
|
17
|
+
require_relative 'otto/helpers'
|
|
20
18
|
require_relative 'otto/response_handlers'
|
|
21
19
|
require_relative 'otto/route_handlers'
|
|
22
|
-
require_relative 'otto/
|
|
23
|
-
require_relative 'otto/
|
|
24
|
-
require_relative 'otto/
|
|
25
|
-
require_relative 'otto/
|
|
26
|
-
require_relative 'otto/security
|
|
27
|
-
require_relative 'otto/security/middleware/rate_limit_middleware'
|
|
28
|
-
require_relative 'otto/mcp/server'
|
|
29
|
-
require_relative 'otto/core/router'
|
|
30
|
-
require_relative 'otto/core/file_safety'
|
|
31
|
-
require_relative 'otto/core/configuration'
|
|
32
|
-
require_relative 'otto/core/error_handler'
|
|
33
|
-
require_relative 'otto/core/uri_generator'
|
|
34
|
-
require_relative 'otto/core/middleware_stack'
|
|
35
|
-
require_relative 'otto/security/configurator'
|
|
20
|
+
require_relative 'otto/locale'
|
|
21
|
+
require_relative 'otto/mcp'
|
|
22
|
+
require_relative 'otto/core'
|
|
23
|
+
require_relative 'otto/privacy'
|
|
24
|
+
require_relative 'otto/security'
|
|
36
25
|
require_relative 'otto/utils'
|
|
26
|
+
require_relative 'otto/version'
|
|
27
|
+
require_relative 'otto/logging_helpers'
|
|
37
28
|
|
|
38
29
|
# Otto is a simple Rack router that allows you to define routes in a file
|
|
39
30
|
# with built-in security features including CSRF protection, input validation,
|
|
@@ -56,37 +47,6 @@ require_relative 'otto/utils'
|
|
|
56
47
|
# otto.enable_csp!
|
|
57
48
|
# otto.enable_frame_protection!
|
|
58
49
|
#
|
|
59
|
-
# Configuration Data class to replace OpenStruct
|
|
60
|
-
# Configuration class to replace OpenStruct
|
|
61
|
-
class ConfigData
|
|
62
|
-
def initialize(**kwargs)
|
|
63
|
-
@data = kwargs
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Dynamic attribute accessors
|
|
67
|
-
def method_missing(method_name, *args)
|
|
68
|
-
if method_name.to_s.end_with?('=')
|
|
69
|
-
# Setter
|
|
70
|
-
attr_name = method_name.to_s.chomp('=').to_sym
|
|
71
|
-
@data[attr_name] = args.first
|
|
72
|
-
elsif @data.key?(method_name)
|
|
73
|
-
# Getter
|
|
74
|
-
@data[method_name]
|
|
75
|
-
else
|
|
76
|
-
super
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def respond_to_missing?(method_name, include_private = false)
|
|
81
|
-
method_name.to_s.end_with?('=') || @data.key?(method_name) || super
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Convert to hash for compatibility
|
|
85
|
-
def to_h
|
|
86
|
-
@data.dup
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
50
|
class Otto
|
|
91
51
|
include Otto::Core::Router
|
|
92
52
|
include Otto::Core::FileSafety
|
|
@@ -102,25 +62,12 @@ class Otto
|
|
|
102
62
|
else
|
|
103
63
|
defined?(Otto::Utils) ? Otto::Utils.yes?(ENV.fetch('OTTO_DEBUG', nil)) : false
|
|
104
64
|
end
|
|
105
|
-
@logger
|
|
106
|
-
@global_config = nil
|
|
107
|
-
|
|
108
|
-
# Global configuration for all Otto instances (Ruby 3.2+ pattern matching)
|
|
109
|
-
def self.configure
|
|
110
|
-
config = case @global_config
|
|
111
|
-
in Hash => h
|
|
112
|
-
# Transform string keys to symbol keys for ConfigData compatibility
|
|
113
|
-
symbol_hash = h.transform_keys(&:to_sym)
|
|
114
|
-
ConfigData.new(**symbol_hash)
|
|
115
|
-
else
|
|
116
|
-
ConfigData.new
|
|
117
|
-
end
|
|
118
|
-
yield config
|
|
119
|
-
@global_config = config.to_h
|
|
120
|
-
end
|
|
65
|
+
@logger = Logger.new($stdout, Logger::INFO)
|
|
121
66
|
|
|
122
|
-
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
|
|
123
|
-
:security_config, :locale_config, :auth_config,
|
|
67
|
+
attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
|
|
68
|
+
:static_route, :security_config, :locale_config, :auth_config,
|
|
69
|
+
:route_handler_factory, :mcp_server, :security, :middleware,
|
|
70
|
+
:error_handlers
|
|
124
71
|
attr_accessor :not_found, :server_error
|
|
125
72
|
|
|
126
73
|
def initialize(path = nil, opts = {})
|
|
@@ -131,27 +78,103 @@ class Otto
|
|
|
131
78
|
Otto.logger.debug "new Otto: #{opts}" if Otto.debug
|
|
132
79
|
load(path) unless path.nil?
|
|
133
80
|
super()
|
|
81
|
+
|
|
82
|
+
# Auto-register AuthorizationError for 403 Forbidden responses
|
|
83
|
+
# This allows Logic classes to raise AuthorizationError for resource-level access control
|
|
84
|
+
register_error_handler(Otto::Security::AuthorizationError, status: 403, log_level: :warn)
|
|
85
|
+
|
|
86
|
+
# Build the middleware app once after all initialization is complete
|
|
87
|
+
build_app!
|
|
88
|
+
|
|
89
|
+
# Configuration freezing is deferred until first request to support
|
|
90
|
+
# multi-step initialization (e.g., multi-app architectures).
|
|
91
|
+
# This allows adding auth strategies, middleware, etc. after Otto.new
|
|
92
|
+
# but before processing requests.
|
|
93
|
+
@freeze_mutex = Mutex.new
|
|
94
|
+
@configuration_frozen = false
|
|
134
95
|
end
|
|
135
96
|
alias options option
|
|
136
97
|
|
|
137
98
|
# Main Rack application interface
|
|
138
99
|
def call(env)
|
|
139
|
-
#
|
|
140
|
-
|
|
100
|
+
# Freeze configuration on first request (thread-safe)
|
|
101
|
+
# Skip in test environment to allow test flexibility
|
|
102
|
+
unless defined?(RSpec) || @configuration_frozen
|
|
103
|
+
Otto.logger.debug '[Otto] Lazy freezing check: configuration not yet frozen' if Otto.debug
|
|
104
|
+
|
|
105
|
+
@freeze_mutex.synchronize do
|
|
106
|
+
unless @configuration_frozen
|
|
107
|
+
Otto.logger.info '[Otto] Freezing configuration on first request (lazy freeze)'
|
|
108
|
+
freeze_configuration!
|
|
109
|
+
@configuration_frozen = true
|
|
110
|
+
Otto.logger.debug '[Otto] Configuration frozen successfully' if Otto.debug
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
141
114
|
|
|
142
|
-
#
|
|
143
|
-
|
|
115
|
+
# Track request timing for lifecycle hooks
|
|
116
|
+
start_time = Otto::Utils.now_in_μs
|
|
117
|
+
request = Rack::Request.new(env)
|
|
118
|
+
response_raw = nil
|
|
144
119
|
|
|
145
120
|
begin
|
|
146
|
-
app
|
|
121
|
+
# Use pre-built middleware app (built once at initialization)
|
|
122
|
+
response_raw = @app.call(env)
|
|
147
123
|
rescue StandardError => e
|
|
148
|
-
handle_error(e, env)
|
|
124
|
+
response_raw = handle_error(e, env)
|
|
125
|
+
ensure
|
|
126
|
+
# Execute request completion hooks if any are registered
|
|
127
|
+
unless @request_complete_callbacks.empty?
|
|
128
|
+
begin
|
|
129
|
+
duration = Otto::Utils.now_in_μs - start_time
|
|
130
|
+
# Wrap response tuple in Rack::Response for developer-friendly API
|
|
131
|
+
# Otto's hook API should provide nice abstractions like Rack::Request/Response
|
|
132
|
+
response = Rack::Response.new(response_raw[2], response_raw[0], response_raw[1])
|
|
133
|
+
@request_complete_callbacks.each do |callback|
|
|
134
|
+
callback.call(request, response, duration)
|
|
135
|
+
end
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
Otto.logger.error "[Otto] Request completion hook error: #{e.message}"
|
|
138
|
+
Otto.logger.debug "[Otto] Hook error backtrace: #{e.backtrace.join("\n")}" if Otto.debug
|
|
139
|
+
end
|
|
140
|
+
end
|
|
149
141
|
end
|
|
142
|
+
|
|
143
|
+
response_raw
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Builds the middleware application chain
|
|
147
|
+
# Called once at initialization and whenever middleware stack changes
|
|
148
|
+
#
|
|
149
|
+
# IMPORTANT: If you have routes with auth requirements, you MUST add session
|
|
150
|
+
# middleware to your middleware stack BEFORE Otto processes requests.
|
|
151
|
+
#
|
|
152
|
+
# Session middleware is required for RouteAuthWrapper to correctly persist
|
|
153
|
+
# session changes during authentication. Common options include:
|
|
154
|
+
# - Rack::Session::Cookie (requires rack-session gem)
|
|
155
|
+
# - Rack::Session::Pool
|
|
156
|
+
# - Rack::Session::Memcache
|
|
157
|
+
# - Any Rack-compatible session middleware
|
|
158
|
+
#
|
|
159
|
+
# Example:
|
|
160
|
+
# use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
|
|
161
|
+
# otto = Otto.new('routes.txt')
|
|
162
|
+
#
|
|
163
|
+
def build_app!
|
|
164
|
+
base_app = method(:handle_request)
|
|
165
|
+
@app = @middleware.wrap(base_app, @security_config)
|
|
150
166
|
end
|
|
151
167
|
|
|
152
168
|
# Middleware Management
|
|
153
169
|
def use(middleware, ...)
|
|
170
|
+
ensure_not_frozen!
|
|
154
171
|
@middleware.add(middleware, ...)
|
|
172
|
+
|
|
173
|
+
# NOTE: If build_app! is triggered during a request (via use() or
|
|
174
|
+
# middleware_stack=), the @app instance variable could be swapped
|
|
175
|
+
# mid-request in a multi-threaded environment.
|
|
176
|
+
|
|
177
|
+
build_app! if @app # Rebuild app if already initialized
|
|
155
178
|
end
|
|
156
179
|
|
|
157
180
|
# Compatibility method for existing tests
|
|
@@ -163,6 +186,7 @@ class Otto
|
|
|
163
186
|
def middleware_stack=(stack)
|
|
164
187
|
@middleware.clear!
|
|
165
188
|
Array(stack).each { |middleware| @middleware.add(middleware) }
|
|
189
|
+
build_app! if @app # Rebuild app if already initialized
|
|
166
190
|
end
|
|
167
191
|
|
|
168
192
|
# Compatibility method for middleware detection
|
|
@@ -179,6 +203,7 @@ class Otto
|
|
|
179
203
|
# @example
|
|
180
204
|
# otto.enable_csrf_protection!
|
|
181
205
|
def enable_csrf_protection!
|
|
206
|
+
ensure_not_frozen!
|
|
182
207
|
return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
|
|
183
208
|
|
|
184
209
|
@security_config.enable_csrf_protection!
|
|
@@ -191,6 +216,7 @@ class Otto
|
|
|
191
216
|
# @example
|
|
192
217
|
# otto.enable_request_validation!
|
|
193
218
|
def enable_request_validation!
|
|
219
|
+
ensure_not_frozen!
|
|
194
220
|
return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
|
|
195
221
|
|
|
196
222
|
@security_config.input_validation = true
|
|
@@ -206,6 +232,7 @@ class Otto
|
|
|
206
232
|
# @example
|
|
207
233
|
# otto.enable_rate_limiting!(requests_per_minute: 50)
|
|
208
234
|
def enable_rate_limiting!(options = {})
|
|
235
|
+
ensure_not_frozen!
|
|
209
236
|
return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
|
|
210
237
|
|
|
211
238
|
@security.configure_rate_limiting(options)
|
|
@@ -222,7 +249,7 @@ class Otto
|
|
|
222
249
|
# @example
|
|
223
250
|
# otto.add_rate_limit_rule('uploads', limit: 5, period: 300, condition: ->(req) { req.post? && req.path.include?('upload') })
|
|
224
251
|
def add_rate_limit_rule(name, options)
|
|
225
|
-
|
|
252
|
+
ensure_not_frozen!
|
|
226
253
|
@security_config.rate_limiting_config[:custom_rules][name.to_s] = options
|
|
227
254
|
end
|
|
228
255
|
|
|
@@ -234,6 +261,7 @@ class Otto
|
|
|
234
261
|
# otto.add_trusted_proxy('10.0.0.0/8')
|
|
235
262
|
# otto.add_trusted_proxy(/^172\.16\./)
|
|
236
263
|
def add_trusted_proxy(proxy)
|
|
264
|
+
ensure_not_frozen!
|
|
237
265
|
@security_config.add_trusted_proxy(proxy)
|
|
238
266
|
end
|
|
239
267
|
|
|
@@ -247,6 +275,7 @@ class Otto
|
|
|
247
275
|
# 'strict-transport-security' => 'max-age=31536000'
|
|
248
276
|
# })
|
|
249
277
|
def set_security_headers(headers)
|
|
278
|
+
ensure_not_frozen!
|
|
250
279
|
@security_config.security_headers.merge!(headers)
|
|
251
280
|
end
|
|
252
281
|
|
|
@@ -259,6 +288,7 @@ class Otto
|
|
|
259
288
|
# @example
|
|
260
289
|
# otto.enable_hsts!(max_age: 86400, include_subdomains: false)
|
|
261
290
|
def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
|
|
291
|
+
ensure_not_frozen!
|
|
262
292
|
@security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
|
|
263
293
|
end
|
|
264
294
|
|
|
@@ -269,6 +299,7 @@ class Otto
|
|
|
269
299
|
# @example
|
|
270
300
|
# otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
|
|
271
301
|
def enable_csp!(policy = "default-src 'self'")
|
|
302
|
+
ensure_not_frozen!
|
|
272
303
|
@security_config.enable_csp!(policy)
|
|
273
304
|
end
|
|
274
305
|
|
|
@@ -278,6 +309,7 @@ class Otto
|
|
|
278
309
|
# @example
|
|
279
310
|
# otto.enable_frame_protection!('DENY')
|
|
280
311
|
def enable_frame_protection!(option = 'SAMEORIGIN')
|
|
312
|
+
ensure_not_frozen!
|
|
281
313
|
@security_config.enable_frame_protection!(option)
|
|
282
314
|
end
|
|
283
315
|
|
|
@@ -288,33 +320,202 @@ class Otto
|
|
|
288
320
|
# @example
|
|
289
321
|
# otto.enable_csp_with_nonce!(debug: true)
|
|
290
322
|
def enable_csp_with_nonce!(debug: false)
|
|
323
|
+
ensure_not_frozen!
|
|
291
324
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
292
325
|
end
|
|
293
326
|
|
|
294
|
-
# Enable authentication middleware for route-level access control.
|
|
295
|
-
# This will automatically check route auth parameters and enforce authentication.
|
|
296
|
-
#
|
|
297
|
-
# @example
|
|
298
|
-
# otto.enable_authentication!
|
|
299
|
-
def enable_authentication!
|
|
300
|
-
return if @middleware.includes?(Otto::Security::Authentication::AuthenticationMiddleware)
|
|
301
|
-
|
|
302
|
-
use Otto::Security::Authentication::AuthenticationMiddleware, @auth_config
|
|
303
|
-
end
|
|
304
|
-
|
|
305
327
|
# Add a single authentication strategy
|
|
306
328
|
#
|
|
307
329
|
# @param name [String] Strategy name
|
|
308
330
|
# @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
|
|
309
331
|
# @example
|
|
310
332
|
# otto.add_auth_strategy('custom', MyCustomStrategy.new)
|
|
333
|
+
# Add an authentication strategy with a registered name
|
|
334
|
+
#
|
|
335
|
+
# This is the primary public API for registering authentication strategies.
|
|
336
|
+
# The name you provide here will be available as `strategy_result.strategy_name`
|
|
337
|
+
# in your application code, making it easy to identify which strategy authenticated
|
|
338
|
+
# the current request.
|
|
339
|
+
#
|
|
340
|
+
# Also available via Otto::Security::Configurator for consolidated security config.
|
|
341
|
+
#
|
|
342
|
+
# @param name [String, Symbol] Strategy name (e.g., 'session', 'api_key', 'jwt')
|
|
343
|
+
# @param strategy [AuthStrategy] Strategy instance
|
|
344
|
+
# @example
|
|
345
|
+
# otto.add_auth_strategy('session', SessionStrategy.new(session_key: 'user_id'))
|
|
346
|
+
# otto.add_auth_strategy('api_key', APIKeyStrategy.new)
|
|
347
|
+
# @raise [ArgumentError] if strategy name already registered
|
|
311
348
|
def add_auth_strategy(name, strategy)
|
|
349
|
+
ensure_not_frozen!
|
|
312
350
|
# Ensure auth_config is initialized (handles edge case where it might be nil)
|
|
313
351
|
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' } if @auth_config.nil?
|
|
314
352
|
|
|
353
|
+
# Strict mode: Detect strategy name collisions
|
|
354
|
+
if @auth_config[:auth_strategies].key?(name)
|
|
355
|
+
raise ArgumentError, "Authentication strategy '#{name}' is already registered"
|
|
356
|
+
end
|
|
357
|
+
|
|
315
358
|
@auth_config[:auth_strategies][name] = strategy
|
|
359
|
+
end
|
|
316
360
|
|
|
317
|
-
|
|
361
|
+
# Register an error handler for expected business logic errors
|
|
362
|
+
#
|
|
363
|
+
# This allows you to handle known error conditions (like missing resources,
|
|
364
|
+
# expired data, rate limits) without logging them as unhandled 500 errors.
|
|
365
|
+
#
|
|
366
|
+
# @param error_class [Class, String] The exception class or class name to handle
|
|
367
|
+
# @param status [Integer] HTTP status code to return (default: 500)
|
|
368
|
+
# @param log_level [Symbol] Log level for expected errors (:info, :warn, :error)
|
|
369
|
+
# @param handler [Proc] Optional block to customize error response
|
|
370
|
+
#
|
|
371
|
+
# @example Basic usage with status code
|
|
372
|
+
# otto.register_error_handler(Onetime::MissingSecret, status: 404, log_level: :info)
|
|
373
|
+
# otto.register_error_handler(Onetime::SecretExpired, status: 410, log_level: :info)
|
|
374
|
+
#
|
|
375
|
+
# @example With custom response handler
|
|
376
|
+
# otto.register_error_handler(Onetime::RateLimited, status: 429, log_level: :warn) do |error, req|
|
|
377
|
+
# {
|
|
378
|
+
# error: 'Rate limit exceeded',
|
|
379
|
+
# retry_after: error.retry_after,
|
|
380
|
+
# message: error.message
|
|
381
|
+
# }
|
|
382
|
+
# end
|
|
383
|
+
#
|
|
384
|
+
# @example Using string class names (for lazy loading)
|
|
385
|
+
# otto.register_error_handler('Onetime::MissingSecret', status: 404, log_level: :info)
|
|
386
|
+
#
|
|
387
|
+
def register_error_handler(error_class, status: 500, log_level: :info, &handler)
|
|
388
|
+
ensure_not_frozen!
|
|
389
|
+
|
|
390
|
+
# Normalize error class to string for consistent lookup
|
|
391
|
+
error_class_name = error_class.is_a?(String) ? error_class : error_class.name
|
|
392
|
+
|
|
393
|
+
@error_handlers[error_class_name] = {
|
|
394
|
+
status: status,
|
|
395
|
+
log_level: log_level,
|
|
396
|
+
handler: handler
|
|
397
|
+
}
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Disable IP privacy to access original IP addresses
|
|
401
|
+
#
|
|
402
|
+
# IMPORTANT: By default, Otto masks public IP addresses for privacy.
|
|
403
|
+
# Private/localhost IPs (127.0.0.0/8, 10.0.0.0/8, etc.) are never masked.
|
|
404
|
+
# Only disable this if you need access to original public IPs.
|
|
405
|
+
#
|
|
406
|
+
# When disabled:
|
|
407
|
+
# - env['REMOTE_ADDR'] contains the real IP address
|
|
408
|
+
# - env['otto.original_ip'] also contains the real IP
|
|
409
|
+
# - No PrivateFingerprint is created
|
|
410
|
+
#
|
|
411
|
+
# @example
|
|
412
|
+
# otto.disable_ip_privacy!
|
|
413
|
+
def disable_ip_privacy!
|
|
414
|
+
ensure_not_frozen!
|
|
415
|
+
@security_config.ip_privacy_config.disable!
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Enable full IP privacy (mask ALL IPs including private/localhost)
|
|
419
|
+
#
|
|
420
|
+
# By default, Otto exempts private and localhost IPs from masking for
|
|
421
|
+
# better development experience. Call this method to mask ALL IPs
|
|
422
|
+
# regardless of type.
|
|
423
|
+
#
|
|
424
|
+
# @example Enable full privacy (mask all IPs)
|
|
425
|
+
# otto = Otto.new(routes_file)
|
|
426
|
+
# otto.enable_full_ip_privacy!
|
|
427
|
+
# # Now 127.0.0.1 → 127.0.0.0, 192.168.1.100 → 192.168.1.0
|
|
428
|
+
#
|
|
429
|
+
# @return [void]
|
|
430
|
+
# @raise [FrozenError] if called after configuration is frozen
|
|
431
|
+
def enable_full_ip_privacy!
|
|
432
|
+
ensure_not_frozen!
|
|
433
|
+
@security_config.ip_privacy_config.mask_private_ips = true
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Register a callback to be executed after each request completes
|
|
437
|
+
#
|
|
438
|
+
# Instance-level request completion callbacks allow each Otto instance
|
|
439
|
+
# to have its own isolated set of callbacks, preventing duplicate
|
|
440
|
+
# invocations in multi-app architectures (e.g., Rack::URLMap).
|
|
441
|
+
#
|
|
442
|
+
# The callback receives three arguments:
|
|
443
|
+
# - request: Rack::Request object
|
|
444
|
+
# - response: Rack::Response object (wrapping the response tuple)
|
|
445
|
+
# - duration: Request processing duration in microseconds
|
|
446
|
+
#
|
|
447
|
+
# @example Basic usage
|
|
448
|
+
# otto = Otto.new(routes_file)
|
|
449
|
+
# otto.on_request_complete do |req, res, duration|
|
|
450
|
+
# logger.info "Request completed", path: req.path, duration: duration
|
|
451
|
+
# end
|
|
452
|
+
#
|
|
453
|
+
# @example Multi-app architecture
|
|
454
|
+
# # App 1: Core Web Application
|
|
455
|
+
# core_router = Otto.new
|
|
456
|
+
# core_router.on_request_complete do |req, res, duration|
|
|
457
|
+
# logger.info "Core app request", path: req.path
|
|
458
|
+
# end
|
|
459
|
+
#
|
|
460
|
+
# # App 2: API Application
|
|
461
|
+
# api_router = Otto.new
|
|
462
|
+
# api_router.on_request_complete do |req, res, duration|
|
|
463
|
+
# logger.info "API request", path: req.path
|
|
464
|
+
# end
|
|
465
|
+
#
|
|
466
|
+
# # Each callback only fires for its respective Otto instance
|
|
467
|
+
#
|
|
468
|
+
# @yield [request, response, duration] Block to execute after each request
|
|
469
|
+
# @yieldparam request [Rack::Request] The request object
|
|
470
|
+
# @yieldparam response [Rack::Response] The response object
|
|
471
|
+
# @yieldparam duration [Integer] Duration in microseconds
|
|
472
|
+
# @return [self] Returns self for method chaining
|
|
473
|
+
# @raise [FrozenError] if called after configuration is frozen
|
|
474
|
+
def on_request_complete(&block)
|
|
475
|
+
ensure_not_frozen!
|
|
476
|
+
@request_complete_callbacks << block if block_given?
|
|
477
|
+
self
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Get registered request completion callbacks (for internal use)
|
|
481
|
+
#
|
|
482
|
+
# @api private
|
|
483
|
+
# @return [Array<Proc>] Array of registered callback blocks
|
|
484
|
+
attr_reader :request_complete_callbacks
|
|
485
|
+
|
|
486
|
+
# Configure IP privacy settings
|
|
487
|
+
#
|
|
488
|
+
# Privacy is enabled by default. Use this method to customize privacy
|
|
489
|
+
# behavior without disabling it entirely.
|
|
490
|
+
#
|
|
491
|
+
# @param octet_precision [Integer] Number of octets to mask (1 or 2, default: 1)
|
|
492
|
+
# @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
|
|
493
|
+
# @param geo [Boolean] Enable geo-location resolution (default: true)
|
|
494
|
+
# @param redis [Redis] Redis connection for multi-server atomic key generation
|
|
495
|
+
#
|
|
496
|
+
# @example Mask 2 octets instead of 1
|
|
497
|
+
# otto.configure_ip_privacy(octet_precision: 2)
|
|
498
|
+
#
|
|
499
|
+
# @example Disable geo-location
|
|
500
|
+
# otto.configure_ip_privacy(geo: false)
|
|
501
|
+
#
|
|
502
|
+
# @example Custom hash rotation
|
|
503
|
+
# otto.configure_ip_privacy(hash_rotation: 24.hours)
|
|
504
|
+
#
|
|
505
|
+
# @example Multi-server with Redis
|
|
506
|
+
# redis = Redis.new(url: ENV['REDIS_URL'])
|
|
507
|
+
# otto.configure_ip_privacy(redis: redis)
|
|
508
|
+
def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil)
|
|
509
|
+
ensure_not_frozen!
|
|
510
|
+
config = @security_config.ip_privacy_config
|
|
511
|
+
|
|
512
|
+
config.octet_precision = octet_precision if octet_precision
|
|
513
|
+
config.hash_rotation_period = hash_rotation if hash_rotation
|
|
514
|
+
config.geo_enabled = geo unless geo.nil?
|
|
515
|
+
config.instance_variable_set(:@redis, redis) if redis
|
|
516
|
+
|
|
517
|
+
# Validate configuration
|
|
518
|
+
config.validate!
|
|
318
519
|
end
|
|
319
520
|
|
|
320
521
|
# Enable MCP (Model Context Protocol) server support
|
|
@@ -326,6 +527,7 @@ class Otto
|
|
|
326
527
|
# @example
|
|
327
528
|
# otto.enable_mcp!(http: true, endpoint: '/api/mcp')
|
|
328
529
|
def enable_mcp!(options = {})
|
|
530
|
+
ensure_not_frozen!
|
|
329
531
|
@mcp_server ||= Otto::MCP::Server.new(self)
|
|
330
532
|
|
|
331
533
|
@mcp_server.enable!(options)
|
|
@@ -350,6 +552,16 @@ class Otto
|
|
|
350
552
|
# Initialize @auth_config first so it can be shared with the configurator
|
|
351
553
|
@auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' }
|
|
352
554
|
@security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
|
|
555
|
+
@app = nil # Pre-built middleware app (built after initialization)
|
|
556
|
+
@request_complete_callbacks = [] # Instance-level request completion callbacks
|
|
557
|
+
@error_handlers = {} # Registered error handlers for expected errors
|
|
558
|
+
|
|
559
|
+
# Add IP Privacy middleware first in stack (privacy by default for public IPs)
|
|
560
|
+
# Private/localhost IPs are automatically exempted from masking
|
|
561
|
+
@middleware.add_with_position(
|
|
562
|
+
Otto::Security::Middleware::IPPrivacyMiddleware,
|
|
563
|
+
position: :first
|
|
564
|
+
)
|
|
353
565
|
end
|
|
354
566
|
|
|
355
567
|
def initialize_options(_path, opts)
|
|
@@ -375,7 +587,30 @@ class Otto
|
|
|
375
587
|
end
|
|
376
588
|
|
|
377
589
|
class << self
|
|
378
|
-
attr_accessor :debug, :logger
|
|
590
|
+
attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
|
591
|
+
|
|
592
|
+
# Helper method for structured logging that works with both standard Logger and structured loggers
|
|
593
|
+
def structured_log(level, message, data = {})
|
|
594
|
+
return unless logger
|
|
595
|
+
|
|
596
|
+
# Skip debug logging when Otto.debug is false
|
|
597
|
+
return if level == :debug && !debug
|
|
598
|
+
|
|
599
|
+
# Sanitize backtrace if present
|
|
600
|
+
if data.is_a?(Hash) && data[:backtrace].is_a?(Array)
|
|
601
|
+
data = data.dup
|
|
602
|
+
data[:backtrace] = Otto::LoggingHelpers.sanitize_backtrace(data[:backtrace])
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Try structured logging first (SemanticLogger, etc.)
|
|
606
|
+
if logger.respond_to?(level) && logger.method(level).arity > 1
|
|
607
|
+
logger.send(level, message, data)
|
|
608
|
+
else
|
|
609
|
+
# Fallback to standard logger with formatted string
|
|
610
|
+
formatted_data = data.empty? ? '' : " -- #{data.inspect}"
|
|
611
|
+
logger.send(level, "[Otto] #{message}#{formatted_data}")
|
|
612
|
+
end
|
|
613
|
+
end
|
|
379
614
|
end
|
|
380
615
|
|
|
381
616
|
# Class methods for Otto framework providing singleton access and configuration
|
|
@@ -398,7 +633,27 @@ class Otto
|
|
|
398
633
|
end
|
|
399
634
|
|
|
400
635
|
def env? *guesses
|
|
401
|
-
|
|
636
|
+
guesses.flatten.any? { |n| ENV['RACK_ENV'].to_s == n.to_s }
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
# Test-only method to unfreeze Otto configuration
|
|
640
|
+
#
|
|
641
|
+
# This method resets the @configuration_frozen flag, allowing tests
|
|
642
|
+
# to bypass the ensure_not_frozen! check. It does NOT actually unfreeze
|
|
643
|
+
# Ruby objects (which is impossible once frozen).
|
|
644
|
+
#
|
|
645
|
+
# IMPORTANT: Only works when RSpec is defined. Raises an error otherwise
|
|
646
|
+
# to prevent accidental use in production.
|
|
647
|
+
#
|
|
648
|
+
# @param otto [Otto] The Otto instance to unfreeze
|
|
649
|
+
# @return [Otto] The unfrozen Otto instance
|
|
650
|
+
# @raise [RuntimeError] if RSpec is not defined (not in test environment)
|
|
651
|
+
# @api private
|
|
652
|
+
def unfreeze_for_testing(otto)
|
|
653
|
+
raise 'Otto.unfreeze_for_testing is only available in RSpec test environment' unless defined?(RSpec)
|
|
654
|
+
|
|
655
|
+
otto.instance_variable_set(:@configuration_frozen, false)
|
|
656
|
+
otto
|
|
402
657
|
end
|
|
403
658
|
end
|
|
404
659
|
extend ClassMethods
|
data/otto.gemspec
CHANGED
|
@@ -10,18 +10,25 @@ Gem::Specification.new do |spec|
|
|
|
10
10
|
spec.email = 'gems@solutious.com'
|
|
11
11
|
spec.authors = ['Delano Mandelbaum']
|
|
12
12
|
spec.license = 'MIT'
|
|
13
|
-
spec.files =
|
|
13
|
+
spec.files = if File.directory?('.git') && system('git --version > /dev/null 2>&1')
|
|
14
|
+
`git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
15
|
+
else
|
|
16
|
+
Dir['**/*'].select { |f| File.file?(f) }.reject { |f| f.match(%r{^(test|spec|features)/}) }
|
|
17
|
+
end
|
|
14
18
|
spec.homepage = 'https://github.com/delano/otto'
|
|
15
19
|
spec.require_paths = ['lib']
|
|
16
20
|
|
|
17
21
|
spec.required_ruby_version = ['>= 3.2', '< 4.0']
|
|
18
22
|
|
|
23
|
+
spec.add_dependency 'concurrent-ruby', '~> 1.3', '< 2.0'
|
|
24
|
+
spec.add_dependency 'ipaddr', '~> 1', '< 2.0'
|
|
25
|
+
|
|
19
26
|
# Logger is not part of the default gems as of Ruby 3.5.0
|
|
20
27
|
spec.add_dependency 'logger', '~> 1', '< 2.0'
|
|
21
28
|
|
|
22
29
|
spec.add_dependency 'rack', '~> 3.1', '< 4.0'
|
|
23
30
|
spec.add_dependency 'rack-parser', '~> 0.7'
|
|
24
|
-
spec.add_dependency 'rexml', '
|
|
31
|
+
spec.add_dependency 'rexml', '~> 3.4'
|
|
25
32
|
|
|
26
33
|
# Security dependencies
|
|
27
34
|
spec.add_dependency 'facets', '~> 3.1'
|