otto 2.0.0.pre2 → 2.0.0.pre3

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +0 -2
  3. data/.github/workflows/claude-code-review.yml +29 -13
  4. data/CLAUDE.md +537 -0
  5. data/Gemfile +2 -1
  6. data/Gemfile.lock +17 -10
  7. data/benchmark_middleware_wrap.rb +163 -0
  8. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
  9. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
  10. data/docs/.gitignore +1 -0
  11. data/docs/ipaddr-encoding-quirk.md +34 -0
  12. data/docs/migrating/v2.0.0-pre2.md +11 -18
  13. data/examples/authentication_strategies/config.ru +0 -1
  14. data/lib/otto/core/configuration.rb +89 -39
  15. data/lib/otto/core/freezable.rb +93 -0
  16. data/lib/otto/core/middleware_stack.rb +24 -17
  17. data/lib/otto/core/router.rb +1 -1
  18. data/lib/otto/core.rb +8 -0
  19. data/lib/otto/env_keys.rb +8 -4
  20. data/lib/otto/helpers/request.rb +80 -2
  21. data/lib/otto/helpers/response.rb +3 -3
  22. data/lib/otto/helpers.rb +4 -0
  23. data/lib/otto/locale/config.rb +56 -0
  24. data/lib/otto/mcp.rb +3 -0
  25. data/lib/otto/privacy/config.rb +199 -0
  26. data/lib/otto/privacy/geo_resolver.rb +115 -0
  27. data/lib/otto/privacy/ip_privacy.rb +175 -0
  28. data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
  29. data/lib/otto/privacy.rb +29 -0
  30. data/lib/otto/route_handlers/base.rb +1 -2
  31. data/lib/otto/route_handlers/factory.rb +16 -14
  32. data/lib/otto/route_handlers/logic_class.rb +2 -2
  33. data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +3 -3
  34. data/lib/otto/security/authentication/auth_strategy.rb +3 -3
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +137 -26
  36. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -1
  37. data/lib/otto/security/authentication.rb +3 -4
  38. data/lib/otto/security/config.rb +51 -7
  39. data/lib/otto/security/configurator.rb +0 -13
  40. data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
  41. data/lib/otto/security.rb +9 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +181 -86
  44. data/otto.gemspec +3 -0
  45. metadata +58 -3
  46. data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Otto
4
+ module Security
5
+ module Middleware
6
+ # IP Privacy Middleware
7
+ #
8
+ # Automatically masks IP addresses for privacy by default. Original IPs
9
+ # are never stored unless privacy is explicitly disabled.
10
+ #
11
+ # This middleware runs FIRST in the stack to ensure all downstream
12
+ # middleware and application code receives masked IPs by default.
13
+ #
14
+ # @example Default behavior (privacy enabled)
15
+ # # env['REMOTE_ADDR'] is masked to 192.168.1.0
16
+ # # env['otto.redacted_fingerprint'] contains full anonymized data
17
+ # # env['otto.original_ip'] is NOT set
18
+ #
19
+ # @example Privacy disabled
20
+ # otto.disable_ip_privacy!
21
+ # # env['REMOTE_ADDR'] contains real IP
22
+ # # env['otto.original_ip'] also contains real IP
23
+ #
24
+ class IPPrivacyMiddleware
25
+ # Initialize IP Privacy middleware
26
+ #
27
+ # @param app [#call] Rack application
28
+ # @param security_config [Otto::Security::Config] Security configuration
29
+ def initialize(app, security_config = nil)
30
+ @app = app
31
+ @security_config = security_config
32
+ @config = security_config&.ip_privacy_config || Otto::Privacy::Config.new
33
+
34
+ # Privacy is enabled by default unless explicitly disabled
35
+ @privacy_enabled = @config.enabled?
36
+ end
37
+
38
+ # Process request with IP privacy
39
+ #
40
+ # @param env [Hash] Rack environment
41
+ # @return [Array] Rack response tuple [status, headers, body]
42
+ def call(env)
43
+ if @privacy_enabled
44
+ apply_privacy(env)
45
+ else
46
+ apply_no_privacy(env)
47
+ end
48
+
49
+ @app.call(env)
50
+ end
51
+
52
+ private
53
+
54
+ # Apply privacy settings to environment
55
+ #
56
+ # @param env [Hash] Rack environment
57
+ # Apply privacy settings to environment
58
+ #
59
+ # @param env [Hash] Rack environment
60
+ # Apply privacy settings to environment
61
+ #
62
+ # @param env [Hash] Rack environment
63
+ def apply_privacy(env)
64
+ # Resolve the actual client IP (handling proxies)
65
+ client_ip = resolve_client_ip(env)
66
+
67
+ Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
68
+
69
+ # Skip masking for private/localhost IPs unless explicitly configured to mask them
70
+ # This provides better DX for development while still protecting public IPs
71
+ unless @config.mask_private_ips
72
+ if Otto::Privacy::IPPrivacy.private_or_localhost?(client_ip)
73
+ # Update REMOTE_ADDR to the resolved client IP (even though it's not masked)
74
+ env['REMOTE_ADDR'] = client_ip
75
+ env['otto.original_ip'] = client_ip
76
+ # Don't mask forwarded headers for private IPs
77
+ Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
78
+ return
79
+ end
80
+ end
81
+
82
+ # Create privacy-safe fingerprint using the resolved client IP
83
+ # We temporarily set REMOTE_ADDR to the client IP for fingerprint creation
84
+ original_remote_addr = env['REMOTE_ADDR']
85
+ env['REMOTE_ADDR'] = client_ip
86
+ fingerprint = Otto::Privacy::RedactedFingerprint.new(env, @config)
87
+ env['REMOTE_ADDR'] = original_remote_addr
88
+
89
+ # Set privacy-safe values in environment
90
+ env['otto.redacted_fingerprint'] = fingerprint
91
+ env['otto.masked_ip'] = fingerprint.masked_ip
92
+ env['otto.hashed_ip'] = fingerprint.hashed_ip
93
+ env['otto.geo_country'] = fingerprint.country
94
+
95
+ # CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked IP
96
+ # This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
97
+ # automatically uses the masked IP without modification
98
+ env['REMOTE_ADDR'] = fingerprint.masked_ip
99
+
100
+ # Mask X-Forwarded-For headers to prevent leakage
101
+ # Replace with masked IP so proxy resolution logic finds the masked IP
102
+ mask_forwarded_headers(env, fingerprint.masked_ip)
103
+
104
+ Otto.logger.debug "[IPPrivacyMiddleware] Masked IP: #{fingerprint.masked_ip}" if Otto.debug
105
+
106
+ # NOTE: We deliberately DO NOT set env['otto.original_ip']
107
+ # This prevents accidental leakage of the real IP address
108
+ end
109
+
110
+
111
+ # Resolve the actual client IP address from the request
112
+ #
113
+ # This method handles proxy scenarios by checking X-Forwarded-For and
114
+ # other proxy headers from trusted proxies, similar to Rack's logic
115
+ # and Otto's client_ipaddress method.
116
+ #
117
+ # @param env [Hash] Rack environment
118
+ # @return [String] Resolved client IP address
119
+ def resolve_client_ip(env)
120
+ remote_addr = env['REMOTE_ADDR']
121
+
122
+ # If we don't have a security config, use direct connection
123
+ return remote_addr unless @security_config
124
+
125
+ # If REMOTE_ADDR is not from a trusted proxy, it's the client IP
126
+ return remote_addr unless trusted_proxy?(remote_addr)
127
+
128
+ # REMOTE_ADDR is from a trusted proxy, check forwarded headers
129
+ forwarded_ips = [
130
+ env['HTTP_X_FORWARDED_FOR'],
131
+ env['HTTP_X_REAL_IP'],
132
+ env['HTTP_X_CLIENT_IP'],
133
+ ].compact.map { |header| header.split(/,\s*/) }.flatten
134
+
135
+ # Return the first valid public IP from forwarded headers
136
+ forwarded_ips.each do |ip|
137
+ clean_ip = validate_ip_address(ip.strip)
138
+ next unless clean_ip
139
+
140
+ # Return first IP that's not from a trusted proxy
141
+ return clean_ip unless trusted_proxy?(clean_ip)
142
+ end
143
+
144
+ # Fallback to remote address if no valid forwarded IPs
145
+ remote_addr
146
+ end
147
+
148
+ # Mask X-Forwarded-For and related proxy headers
149
+ #
150
+ # Replaces forwarded IP headers with the masked IP to prevent leakage
151
+ # when downstream code (including Rack's request.ip) parses these headers.
152
+ #
153
+ # @param env [Hash] Rack environment
154
+ # @param masked_ip [String] The masked IP to use as replacement
155
+ def mask_forwarded_headers(env, masked_ip)
156
+ # Replace X-Forwarded-For with masked IP
157
+ # This prevents Rack::Request#ip from finding the real IP
158
+ env['HTTP_X_FORWARDED_FOR'] = masked_ip if env['HTTP_X_FORWARDED_FOR']
159
+ env['HTTP_X_REAL_IP'] = masked_ip if env['HTTP_X_REAL_IP']
160
+ env['HTTP_X_CLIENT_IP'] = masked_ip if env['HTTP_X_CLIENT_IP']
161
+
162
+ Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
163
+ end
164
+
165
+ # Check if an IP is from a trusted proxy
166
+ #
167
+ # @param ip [String] IP address to check
168
+ # @return [Boolean] true if IP is from a trusted proxy
169
+ def trusted_proxy?(ip)
170
+ return false unless @security_config
171
+
172
+ @security_config.trusted_proxy?(ip)
173
+ end
174
+
175
+ # Validate and clean IP address
176
+ #
177
+ # @param ip [String, nil] IP address to validate
178
+ # @return [String, nil] Cleaned IP or nil if invalid
179
+ def validate_ip_address(ip)
180
+ return nil if ip.nil? || ip.empty?
181
+
182
+ # Remove any port number
183
+ clean_ip = ip.split(':').first
184
+
185
+ # Basic IPv4 format validation
186
+ return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
187
+
188
+ # Validate each octet
189
+ octets = clean_ip.split('.')
190
+ return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
191
+
192
+ clean_ip
193
+ end
194
+
195
+ # Apply no-privacy settings (privacy explicitly disabled)
196
+ #
197
+ # When privacy is disabled, original IP is available for
198
+ # backward compatibility with code that requires it.
199
+ #
200
+ # @param env [Hash] Rack environment
201
+ def apply_no_privacy(env)
202
+ # Store original IP for explicit access
203
+ env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
204
+
205
+ # env['REMOTE_ADDR'] remains unchanged (real IP)
206
+ # No fingerprint is created when privacy is disabled
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,9 @@
1
+ # lib/otto/security.rb
2
+
3
+ require_relative 'security/authentication/strategy_result'
4
+ require_relative 'security/config'
5
+ require_relative 'security/configurator'
6
+ require_relative 'security/middleware/csrf_middleware'
7
+ require_relative 'security/middleware/validation_middleware'
8
+ require_relative 'security/middleware/rate_limit_middleware'
9
+ require_relative 'security/middleware/ip_privacy_middleware'
data/lib/otto/version.rb CHANGED
@@ -3,5 +3,5 @@
3
3
  # lib/otto/version.rb
4
4
 
5
5
  class Otto
6
- VERSION = '2.0.0.pre2'
6
+ VERSION = '2.0.0.pre3'
7
7
  end
data/lib/otto.rb CHANGED
@@ -11,29 +11,19 @@ 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/request'
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/version'
23
- require_relative 'otto/security/config'
24
- require_relative 'otto/security/middleware/csrf_middleware'
25
- require_relative 'otto/security/middleware/validation_middleware'
26
- require_relative 'otto/security/authentication/authentication_middleware'
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/config'
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'
37
27
 
38
28
  # Otto is a simple Rack router that allows you to define routes in a file
39
29
  # with built-in security features including CSRF protection, input validation,
@@ -56,37 +46,6 @@ require_relative 'otto/utils'
56
46
  # otto.enable_csp!
57
47
  # otto.enable_frame_protection!
58
48
  #
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
49
  class Otto
91
50
  include Otto::Core::Router
92
51
  include Otto::Core::FileSafety
@@ -102,25 +61,11 @@ class Otto
102
61
  else
103
62
  defined?(Otto::Utils) ? Otto::Utils.yes?(ENV.fetch('OTTO_DEBUG', nil)) : false
104
63
  end
105
- @logger = Logger.new($stdout, Logger::INFO)
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
64
+ @logger = Logger.new($stdout, Logger::INFO)
121
65
 
122
- attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option, :static_route,
123
- :security_config, :locale_config, :auth_config, :route_handler_factory, :mcp_server, :security, :middleware
66
+ attr_reader :routes, :routes_literal, :routes_static, :route_definitions, :option,
67
+ :static_route, :security_config, :locale_config, :auth_config,
68
+ :route_handler_factory, :mcp_server, :security, :middleware
124
69
  attr_accessor :not_found, :server_error
125
70
 
126
71
  def initialize(path = nil, opts = {})
@@ -131,27 +76,76 @@ class Otto
131
76
  Otto.logger.debug "new Otto: #{opts}" if Otto.debug
132
77
  load(path) unless path.nil?
133
78
  super()
79
+
80
+ # Build the middleware app once after all initialization is complete
81
+ build_app!
82
+
83
+ # Configuration freezing is deferred until first request to support
84
+ # multi-step initialization (e.g., multi-app architectures).
85
+ # This allows adding auth strategies, middleware, etc. after Otto.new
86
+ # but before processing requests.
87
+ @freeze_mutex = Mutex.new
88
+ @configuration_frozen = false
134
89
  end
135
90
  alias options option
136
91
 
137
92
  # Main Rack application interface
138
93
  def call(env)
139
- # Apply middleware stack
140
- base_app = ->(e) { handle_request(e) }
141
-
142
- # Use the middleware stack as the source of truth
143
- app = @middleware.build_app(base_app, @security_config)
94
+ # Freeze configuration on first request (thread-safe)
95
+ # Skip in test environment to allow test flexibility
96
+ unless defined?(RSpec) || @configuration_frozen
97
+ Otto.logger.debug '[Otto] Lazy freezing check: configuration not yet frozen' if Otto.debug
98
+
99
+ @freeze_mutex.synchronize do
100
+ unless @configuration_frozen
101
+ Otto.logger.info '[Otto] Freezing configuration on first request (lazy freeze)'
102
+ freeze_configuration!
103
+ @configuration_frozen = true
104
+ Otto.logger.debug '[Otto] Configuration frozen successfully' if Otto.debug
105
+ end
106
+ end
107
+ end
144
108
 
145
109
  begin
146
- app.call(env)
110
+ # Use pre-built middleware app (built once at initialization)
111
+ @app.call(env)
147
112
  rescue StandardError => e
148
113
  handle_error(e, env)
149
114
  end
150
115
  end
151
116
 
117
+ # Builds the middleware application chain
118
+ # Called once at initialization and whenever middleware stack changes
119
+ #
120
+ # IMPORTANT: If you have routes with auth requirements, you MUST add session
121
+ # middleware to your middleware stack BEFORE Otto processes requests.
122
+ #
123
+ # Session middleware is required for RouteAuthWrapper to correctly persist
124
+ # session changes during authentication. Common options include:
125
+ # - Rack::Session::Cookie (requires rack-session gem)
126
+ # - Rack::Session::Pool
127
+ # - Rack::Session::Memcache
128
+ # - Any Rack-compatible session middleware
129
+ #
130
+ # Example:
131
+ # use Rack::Session::Cookie, secret: ENV['SESSION_SECRET']
132
+ # otto = Otto.new('routes.txt')
133
+ #
134
+ def build_app!
135
+ base_app = method(:handle_request)
136
+ @app = @middleware.wrap(base_app, @security_config)
137
+ end
138
+
152
139
  # Middleware Management
153
140
  def use(middleware, ...)
141
+ ensure_not_frozen!
154
142
  @middleware.add(middleware, ...)
143
+
144
+ # NOTE: If build_app! is triggered during a request (via use() or
145
+ # middleware_stack=), the @app instance variable could be swapped
146
+ # mid-request in a multi-threaded environment.
147
+
148
+ build_app! if @app # Rebuild app if already initialized
155
149
  end
156
150
 
157
151
  # Compatibility method for existing tests
@@ -163,6 +157,7 @@ class Otto
163
157
  def middleware_stack=(stack)
164
158
  @middleware.clear!
165
159
  Array(stack).each { |middleware| @middleware.add(middleware) }
160
+ build_app! if @app # Rebuild app if already initialized
166
161
  end
167
162
 
168
163
  # Compatibility method for middleware detection
@@ -179,6 +174,7 @@ class Otto
179
174
  # @example
180
175
  # otto.enable_csrf_protection!
181
176
  def enable_csrf_protection!
177
+ ensure_not_frozen!
182
178
  return if @middleware.includes?(Otto::Security::Middleware::CSRFMiddleware)
183
179
 
184
180
  @security_config.enable_csrf_protection!
@@ -191,6 +187,7 @@ class Otto
191
187
  # @example
192
188
  # otto.enable_request_validation!
193
189
  def enable_request_validation!
190
+ ensure_not_frozen!
194
191
  return if @middleware.includes?(Otto::Security::Middleware::ValidationMiddleware)
195
192
 
196
193
  @security_config.input_validation = true
@@ -206,6 +203,7 @@ class Otto
206
203
  # @example
207
204
  # otto.enable_rate_limiting!(requests_per_minute: 50)
208
205
  def enable_rate_limiting!(options = {})
206
+ ensure_not_frozen!
209
207
  return if @middleware.includes?(Otto::Security::Middleware::RateLimitMiddleware)
210
208
 
211
209
  @security.configure_rate_limiting(options)
@@ -222,7 +220,7 @@ class Otto
222
220
  # @example
223
221
  # otto.add_rate_limit_rule('uploads', limit: 5, period: 300, condition: ->(req) { req.post? && req.path.include?('upload') })
224
222
  def add_rate_limit_rule(name, options)
225
- @security_config.rate_limiting_config[:custom_rules] ||= {}
223
+ ensure_not_frozen!
226
224
  @security_config.rate_limiting_config[:custom_rules][name.to_s] = options
227
225
  end
228
226
 
@@ -234,6 +232,7 @@ class Otto
234
232
  # otto.add_trusted_proxy('10.0.0.0/8')
235
233
  # otto.add_trusted_proxy(/^172\.16\./)
236
234
  def add_trusted_proxy(proxy)
235
+ ensure_not_frozen!
237
236
  @security_config.add_trusted_proxy(proxy)
238
237
  end
239
238
 
@@ -247,6 +246,7 @@ class Otto
247
246
  # 'strict-transport-security' => 'max-age=31536000'
248
247
  # })
249
248
  def set_security_headers(headers)
249
+ ensure_not_frozen!
250
250
  @security_config.security_headers.merge!(headers)
251
251
  end
252
252
 
@@ -259,6 +259,7 @@ class Otto
259
259
  # @example
260
260
  # otto.enable_hsts!(max_age: 86400, include_subdomains: false)
261
261
  def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
262
+ ensure_not_frozen!
262
263
  @security_config.enable_hsts!(max_age: max_age, include_subdomains: include_subdomains)
263
264
  end
264
265
 
@@ -269,6 +270,7 @@ class Otto
269
270
  # @example
270
271
  # otto.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
271
272
  def enable_csp!(policy = "default-src 'self'")
273
+ ensure_not_frozen!
272
274
  @security_config.enable_csp!(policy)
273
275
  end
274
276
 
@@ -278,6 +280,7 @@ class Otto
278
280
  # @example
279
281
  # otto.enable_frame_protection!('DENY')
280
282
  def enable_frame_protection!(option = 'SAMEORIGIN')
283
+ ensure_not_frozen!
281
284
  @security_config.enable_frame_protection!(option)
282
285
  end
283
286
 
@@ -288,20 +291,10 @@ class Otto
288
291
  # @example
289
292
  # otto.enable_csp_with_nonce!(debug: true)
290
293
  def enable_csp_with_nonce!(debug: false)
294
+ ensure_not_frozen!
291
295
  @security_config.enable_csp_with_nonce!(debug: debug)
292
296
  end
293
297
 
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
298
  # Add a single authentication strategy
306
299
  #
307
300
  # @param name [String] Strategy name
@@ -309,12 +302,83 @@ class Otto
309
302
  # @example
310
303
  # otto.add_auth_strategy('custom', MyCustomStrategy.new)
311
304
  def add_auth_strategy(name, strategy)
305
+ ensure_not_frozen!
312
306
  # Ensure auth_config is initialized (handles edge case where it might be nil)
313
307
  @auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' } if @auth_config.nil?
314
308
 
315
309
  @auth_config[:auth_strategies][name] = strategy
310
+ end
316
311
 
317
- enable_authentication!
312
+ # Disable IP privacy to access original IP addresses
313
+ #
314
+ # IMPORTANT: By default, Otto masks public IP addresses for privacy.
315
+ # Private/localhost IPs (127.0.0.0/8, 10.0.0.0/8, etc.) are never masked.
316
+ # Only disable this if you need access to original public IPs.
317
+ #
318
+ # When disabled:
319
+ # - env['REMOTE_ADDR'] contains the real IP address
320
+ # - env['otto.original_ip'] also contains the real IP
321
+ # - No PrivateFingerprint is created
322
+ #
323
+ # @example
324
+ # otto.disable_ip_privacy!
325
+ def disable_ip_privacy!
326
+ ensure_not_frozen!
327
+ @security_config.ip_privacy_config.disable!
328
+ end
329
+
330
+
331
+ # Enable full IP privacy (mask ALL IPs including private/localhost)
332
+ #
333
+ # By default, Otto exempts private and localhost IPs from masking for
334
+ # better development experience. Call this method to mask ALL IPs
335
+ # regardless of type.
336
+ #
337
+ # @example Enable full privacy (mask all IPs)
338
+ # otto = Otto.new(routes_file)
339
+ # otto.enable_full_ip_privacy!
340
+ # # Now 127.0.0.1 → 127.0.0.0, 192.168.1.100 → 192.168.1.0
341
+ #
342
+ # @return [void]
343
+ # @raise [FrozenError] if called after configuration is frozen
344
+ def enable_full_ip_privacy!
345
+ ensure_not_frozen!
346
+ @security_config.ip_privacy_config.mask_private_ips = true
347
+ end
348
+
349
+ # Configure IP privacy settings
350
+ #
351
+ # Privacy is enabled by default. Use this method to customize privacy
352
+ # behavior without disabling it entirely.
353
+ #
354
+ # @param octet_precision [Integer] Number of octets to mask (1 or 2, default: 1)
355
+ # @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
356
+ # @param geo [Boolean] Enable geo-location resolution (default: true)
357
+ # @param redis [Redis] Redis connection for multi-server atomic key generation
358
+ #
359
+ # @example Mask 2 octets instead of 1
360
+ # otto.configure_ip_privacy(octet_precision: 2)
361
+ #
362
+ # @example Disable geo-location
363
+ # otto.configure_ip_privacy(geo: false)
364
+ #
365
+ # @example Custom hash rotation
366
+ # otto.configure_ip_privacy(hash_rotation: 24.hours)
367
+ #
368
+ # @example Multi-server with Redis
369
+ # redis = Redis.new(url: ENV['REDIS_URL'])
370
+ # otto.configure_ip_privacy(redis: redis)
371
+ def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil)
372
+ ensure_not_frozen!
373
+ config = @security_config.ip_privacy_config
374
+
375
+ config.octet_precision = octet_precision if octet_precision
376
+ config.hash_rotation_period = hash_rotation if hash_rotation
377
+ config.geo_enabled = geo unless geo.nil?
378
+ config.instance_variable_set(:@redis, redis) if redis
379
+
380
+ # Validate configuration
381
+ config.validate!
318
382
  end
319
383
 
320
384
  # Enable MCP (Model Context Protocol) server support
@@ -326,6 +390,7 @@ class Otto
326
390
  # @example
327
391
  # otto.enable_mcp!(http: true, endpoint: '/api/mcp')
328
392
  def enable_mcp!(options = {})
393
+ ensure_not_frozen!
329
394
  @mcp_server ||= Otto::MCP::Server.new(self)
330
395
 
331
396
  @mcp_server.enable!(options)
@@ -350,6 +415,14 @@ class Otto
350
415
  # Initialize @auth_config first so it can be shared with the configurator
351
416
  @auth_config = { auth_strategies: {}, default_auth_strategy: 'noauth' }
352
417
  @security = Otto::Security::Configurator.new(@security_config, @middleware, @auth_config)
418
+ @app = nil # Pre-built middleware app (built after initialization)
419
+
420
+ # Add IP Privacy middleware first in stack (privacy by default for public IPs)
421
+ # Private/localhost IPs are automatically exempted from masking
422
+ @middleware.add_with_position(
423
+ Otto::Security::Middleware::IPPrivacyMiddleware,
424
+ position: :first
425
+ )
353
426
  end
354
427
 
355
428
  def initialize_options(_path, opts)
@@ -375,7 +448,7 @@ class Otto
375
448
  end
376
449
 
377
450
  class << self
378
- attr_accessor :debug, :logger, :global_config # rubocop:disable ThreadSafety/ClassAndModuleAttributes
451
+ attr_accessor :debug, :logger # rubocop:disable ThreadSafety/ClassAndModuleAttributes
379
452
  end
380
453
 
381
454
  # Class methods for Otto framework providing singleton access and configuration
@@ -400,6 +473,28 @@ class Otto
400
473
  def env? *guesses
401
474
  !guesses.flatten.select { |n| ENV['RACK_ENV'].to_s == n.to_s }.empty?
402
475
  end
476
+
477
+ # Test-only method to unfreeze Otto configuration
478
+ #
479
+ # This method resets the @configuration_frozen flag, allowing tests
480
+ # to bypass the ensure_not_frozen! check. It does NOT actually unfreeze
481
+ # Ruby objects (which is impossible once frozen).
482
+ #
483
+ # IMPORTANT: Only works when RSpec is defined. Raises an error otherwise
484
+ # to prevent accidental use in production.
485
+ #
486
+ # @param otto [Otto] The Otto instance to unfreeze
487
+ # @return [Otto] The unfrozen Otto instance
488
+ # @raise [RuntimeError] if RSpec is not defined (not in test environment)
489
+ # @api private
490
+ def unfreeze_for_testing(otto)
491
+ unless defined?(RSpec)
492
+ raise 'Otto.unfreeze_for_testing is only available in RSpec test environment'
493
+ end
494
+
495
+ otto.instance_variable_set(:@configuration_frozen, false)
496
+ otto
497
+ end
403
498
  end
404
499
  extend ClassMethods
405
500
  end
data/otto.gemspec CHANGED
@@ -16,6 +16,9 @@ Gem::Specification.new do |spec|
16
16
 
17
17
  spec.required_ruby_version = ['>= 3.2', '< 4.0']
18
18
 
19
+ spec.add_dependency 'ipaddr', '~> 1', '< 2.0'
20
+ spec.add_dependency 'concurrent-ruby', '~> 1.3', '< 2.0'
21
+
19
22
  # Logger is not part of the default gems as of Ruby 3.5.0
20
23
  spec.add_dependency 'logger', '~> 1', '< 2.0'
21
24