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.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -3
  3. data/.github/workflows/claude-code-review.yml +29 -13
  4. data/.github/workflows/code-smells.yml +146 -0
  5. data/.gitignore +4 -0
  6. data/.pre-commit-config.yaml +2 -2
  7. data/.reek.yml +99 -0
  8. data/CHANGELOG.rst +90 -0
  9. data/CLAUDE.md +116 -45
  10. data/Gemfile +5 -2
  11. data/Gemfile.lock +70 -24
  12. data/README.md +49 -1
  13. data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
  14. data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
  15. data/docs/.gitignore +1 -0
  16. data/docs/ipaddr-encoding-quirk.md +34 -0
  17. data/docs/migrating/v2.0.0-pre2.md +11 -18
  18. data/examples/advanced_routes/README.md +137 -20
  19. data/examples/authentication_strategies/README.md +212 -19
  20. data/examples/authentication_strategies/config.ru +0 -1
  21. data/examples/backtrace_sanitization_demo.rb +86 -0
  22. data/examples/basic/README.md +61 -10
  23. data/examples/error_handler_registration.rb +136 -0
  24. data/examples/logging_improvements.rb +76 -0
  25. data/examples/mcp_demo/README.md +187 -27
  26. data/examples/security_features/README.md +249 -30
  27. data/examples/simple_geo_resolver.rb +107 -0
  28. data/lib/otto/core/configuration.rb +90 -45
  29. data/lib/otto/core/error_handler.rb +138 -8
  30. data/lib/otto/core/file_safety.rb +2 -2
  31. data/lib/otto/core/freezable.rb +93 -0
  32. data/lib/otto/core/middleware_stack.rb +25 -18
  33. data/lib/otto/core/router.rb +62 -9
  34. data/lib/otto/core/uri_generator.rb +2 -2
  35. data/lib/otto/core.rb +10 -0
  36. data/lib/otto/design_system.rb +2 -2
  37. data/lib/otto/env_keys.rb +65 -12
  38. data/lib/otto/helpers/base.rb +2 -2
  39. data/lib/otto/helpers/request.rb +85 -2
  40. data/lib/otto/helpers/response.rb +5 -5
  41. data/lib/otto/helpers/validation.rb +2 -2
  42. data/lib/otto/helpers.rb +6 -0
  43. data/lib/otto/locale/config.rb +56 -0
  44. data/lib/otto/locale/middleware.rb +160 -0
  45. data/lib/otto/locale.rb +10 -0
  46. data/lib/otto/logging_helpers.rb +273 -0
  47. data/lib/otto/mcp/auth/token.rb +2 -2
  48. data/lib/otto/mcp/protocol.rb +2 -2
  49. data/lib/otto/mcp/rate_limiting.rb +2 -2
  50. data/lib/otto/mcp/registry.rb +2 -2
  51. data/lib/otto/mcp/route_parser.rb +2 -2
  52. data/lib/otto/mcp/schema_validation.rb +2 -2
  53. data/lib/otto/mcp/server.rb +2 -2
  54. data/lib/otto/mcp.rb +5 -0
  55. data/lib/otto/privacy/config.rb +201 -0
  56. data/lib/otto/privacy/geo_resolver.rb +285 -0
  57. data/lib/otto/privacy/ip_privacy.rb +177 -0
  58. data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
  59. data/lib/otto/privacy.rb +31 -0
  60. data/lib/otto/response_handlers/auto.rb +2 -0
  61. data/lib/otto/response_handlers/base.rb +2 -0
  62. data/lib/otto/response_handlers/default.rb +2 -0
  63. data/lib/otto/response_handlers/factory.rb +2 -0
  64. data/lib/otto/response_handlers/json.rb +2 -0
  65. data/lib/otto/response_handlers/redirect.rb +2 -0
  66. data/lib/otto/response_handlers/view.rb +2 -0
  67. data/lib/otto/response_handlers.rb +2 -2
  68. data/lib/otto/route.rb +4 -4
  69. data/lib/otto/route_definition.rb +42 -15
  70. data/lib/otto/route_handlers/base.rb +2 -1
  71. data/lib/otto/route_handlers/class_method.rb +18 -25
  72. data/lib/otto/route_handlers/factory.rb +18 -16
  73. data/lib/otto/route_handlers/instance_method.rb +8 -5
  74. data/lib/otto/route_handlers/lambda.rb +8 -20
  75. data/lib/otto/route_handlers/logic_class.rb +25 -8
  76. data/lib/otto/route_handlers.rb +2 -2
  77. data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
  78. data/lib/otto/security/authentication/auth_strategy.rb +13 -6
  79. data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
  80. data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
  81. data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
  82. data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
  83. data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
  84. data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
  85. data/lib/otto/security/authentication/strategy_result.rb +6 -5
  86. data/lib/otto/security/authentication.rb +5 -6
  87. data/lib/otto/security/authorization_error.rb +73 -0
  88. data/lib/otto/security/config.rb +53 -9
  89. data/lib/otto/security/configurator.rb +17 -15
  90. data/lib/otto/security/csrf.rb +2 -2
  91. data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
  92. data/lib/otto/security/middleware/ip_privacy_middleware.rb +231 -0
  93. data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
  94. data/lib/otto/security/middleware/validation_middleware.rb +15 -0
  95. data/lib/otto/security/rate_limiter.rb +2 -2
  96. data/lib/otto/security/rate_limiting.rb +2 -2
  97. data/lib/otto/security/validator.rb +2 -2
  98. data/lib/otto/security.rb +12 -0
  99. data/lib/otto/static.rb +2 -2
  100. data/lib/otto/utils.rb +27 -2
  101. data/lib/otto/version.rb +3 -3
  102. data/lib/otto.rb +344 -89
  103. data/otto.gemspec +9 -2
  104. metadata +72 -8
  105. data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
@@ -1,9 +1,10 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/config.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'securerandom'
6
6
  require 'digest'
7
+ require_relative '../core/freezable'
7
8
 
8
9
  class Otto
9
10
  module Security
@@ -23,12 +24,15 @@ class Otto
23
24
  # config.max_request_size = 5 * 1024 * 1024 # 5MB
24
25
  # config.max_param_depth = 16
25
26
  class Config
26
- attr_accessor :csrf_protection, :csrf_token_key, :csrf_header_key, :csrf_session_key,
27
- :max_request_size, :max_param_depth, :max_param_keys,
28
- :trusted_proxies, :require_secure_cookies,
29
- :security_headers, :input_validation,
30
- :csp_nonce_enabled, :debug_csp, :mcp_auth,
31
- :rate_limiting_config
27
+ include Otto::Core::Freezable
28
+
29
+ attr_accessor :input_validation, :max_param_depth, :csrf_token_key, :rate_limiting_config, :csrf_session_key, :max_request_size, :max_param_keys
30
+
31
+ attr_reader :csrf_protection, :csrf_header_key,
32
+ :trusted_proxies, :require_secure_cookies,
33
+ :security_headers,
34
+ :csp_nonce_enabled, :debug_csp, :mcp_auth,
35
+ :ip_privacy_config
32
36
 
33
37
  # Initialize security configuration with safe defaults
34
38
  #
@@ -48,7 +52,8 @@ class Otto
48
52
  @input_validation = true
49
53
  @csp_nonce_enabled = false
50
54
  @debug_csp = false
51
- @rate_limiting_config = {}
55
+ @rate_limiting_config = { custom_rules: {} }
56
+ @ip_privacy_config = Otto::Privacy::Config.new
52
57
  end
53
58
 
54
59
  # Enable CSRF (Cross-Site Request Forgery) protection
@@ -60,14 +65,20 @@ class Otto
60
65
  # - Provide helper methods for forms and AJAX requests
61
66
  #
62
67
  # @return [void]
68
+ # @raise [FrozenError] if configuration is frozen
63
69
  def enable_csrf_protection!
70
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
71
+
64
72
  @csrf_protection = true
65
73
  end
66
74
 
67
75
  # Disable CSRF protection
68
76
  #
69
77
  # @return [void]
78
+ # @raise [FrozenError] if configuration is frozen
70
79
  def disable_csrf_protection!
80
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
81
+
71
82
  @csrf_protection = false
72
83
  end
73
84
 
@@ -86,6 +97,7 @@ class Otto
86
97
  #
87
98
  # @param proxy [String, Array] IP address, CIDR range, or array of addresses
88
99
  # @raise [ArgumentError] if proxy is not a String or Array
100
+ # @raise [FrozenError] if configuration is frozen
89
101
  # @return [void]
90
102
  #
91
103
  # @example Add single proxy
@@ -97,6 +109,8 @@ class Otto
97
109
  # @example Add multiple proxies
98
110
  # config.add_trusted_proxy(['10.0.0.1', '172.16.0.0/12'])
99
111
  def add_trusted_proxy(proxy)
112
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
113
+
100
114
  case proxy
101
115
  when String, Regexp
102
116
  @trusted_proxies << proxy
@@ -175,7 +189,10 @@ class Otto
175
189
  # @param max_age [Integer] Maximum age in seconds (default: 1 year)
176
190
  # @param include_subdomains [Boolean] Apply to all subdomains (default: true)
177
191
  # @return [void]
192
+ # @raise [FrozenError] if configuration is frozen
178
193
  def enable_hsts!(max_age: 31_536_000, include_subdomains: true)
194
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
195
+
179
196
  hsts_value = "max-age=#{max_age}"
180
197
  hsts_value += '; includeSubDomains' if include_subdomains
181
198
  @security_headers['strict-transport-security'] = hsts_value
@@ -188,10 +205,13 @@ class Otto
188
205
  #
189
206
  # @param policy [String] CSP policy string (default: "default-src 'self'")
190
207
  # @return [void]
208
+ # @raise [FrozenError] if configuration is frozen
191
209
  #
192
210
  # @example Custom policy
193
211
  # config.enable_csp!("default-src 'self'; script-src 'self' 'unsafe-inline'")
194
212
  def enable_csp!(policy = "default-src 'self'")
213
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
214
+
195
215
  @security_headers['content-security-policy'] = policy
196
216
  end
197
217
 
@@ -203,10 +223,13 @@ class Otto
203
223
  #
204
224
  # @param debug [Boolean] Enable debug logging for CSP headers (default: false)
205
225
  # @return [void]
226
+ # @raise [FrozenError] if configuration is frozen
206
227
  #
207
228
  # @example
208
229
  # config.enable_csp_with_nonce!(debug: true)
209
230
  def enable_csp_with_nonce!(debug: false)
231
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
232
+
210
233
  @csp_nonce_enabled = true
211
234
  @debug_csp = debug
212
235
  end
@@ -214,7 +237,10 @@ class Otto
214
237
  # Disable CSP nonce support
215
238
  #
216
239
  # @return [void]
240
+ # @raise [FrozenError] if configuration is frozen
217
241
  def disable_csp_nonce!
242
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
243
+
218
244
  @csp_nonce_enabled = false
219
245
  end
220
246
 
@@ -246,7 +272,10 @@ class Otto
246
272
  #
247
273
  # @param option [String] Frame options: 'DENY', 'SAMEORIGIN', or 'ALLOW-FROM uri'
248
274
  # @return [void]
275
+ # @raise [FrozenError] if configuration is frozen
249
276
  def enable_frame_protection!(option = 'SAMEORIGIN')
277
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
278
+
250
279
  @security_headers['x-frame-options'] = option
251
280
  end
252
281
 
@@ -254,6 +283,7 @@ class Otto
254
283
  #
255
284
  # @param headers [Hash] Hash of header name => value pairs
256
285
  # @return [void]
286
+ # @raise [FrozenError] if configuration is frozen
257
287
  #
258
288
  # @example
259
289
  # config.set_custom_headers({
@@ -261,9 +291,23 @@ class Otto
261
291
  # 'cross-origin-opener-policy' => 'same-origin'
262
292
  # })
263
293
  def set_custom_headers(headers)
294
+ raise FrozenError, 'Cannot modify frozen configuration' if frozen?
295
+
264
296
  @security_headers.merge!(headers)
265
297
  end
266
298
 
299
+ # Override deep_freeze! to ensure rate_limiting_config has custom_rules initialized
300
+ #
301
+ # This pre-initializes any lazy values before freezing to prevent FrozenError
302
+ # when accessing configuration after it's frozen.
303
+ #
304
+ # @return [self] The frozen configuration
305
+ def deep_freeze!
306
+ # Ensure custom_rules is initialized (should already be done in constructor)
307
+ @rate_limiting_config[:custom_rules] ||= {}
308
+ super
309
+ end
310
+
267
311
  def get_or_create_session_id(request)
268
312
  # Try existing sources first
269
313
  session_id = extract_existing_session_id(request)
@@ -1,10 +1,9 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/configurator.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require_relative 'middleware/csrf_middleware'
6
6
  require_relative 'middleware/validation_middleware'
7
- require_relative 'authentication/authentication_middleware'
8
7
  require_relative 'middleware/rate_limit_middleware'
9
8
 
10
9
  # Security configuration facade for Otto framework
@@ -75,7 +74,6 @@ class Otto
75
74
  enable_hsts! if hsts
76
75
  enable_csp! if csp
77
76
  enable_frame_protection! if frame_protection
78
- enable_authentication! if authentication
79
77
  end
80
78
 
81
79
  # Enable CSRF protection for POST, PUT, DELETE, and PATCH requests.
@@ -118,7 +116,6 @@ class Otto
118
116
  # @option options [Integer] :period Time period in seconds (default: 60)
119
117
  # @option options [Proc] :condition Optional condition proc that receives request
120
118
  def add_rate_limit_rule(name, options)
121
- @security_config.rate_limiting_config[:custom_rules] ||= {}
122
119
  @security_config.rate_limiting_config[:custom_rules][name.to_s] = options
123
120
  end
124
121
 
@@ -171,21 +168,27 @@ class Otto
171
168
  @security_config.enable_csp_with_nonce!(debug: debug)
172
169
  end
173
170
 
174
- # Enable authentication middleware for route-level access control.
175
- # This will automatically check route auth parameters and enforce authentication.
176
- def enable_authentication!
177
- return if middleware_enabled?(Otto::Security::Authentication::AuthenticationMiddleware)
178
-
179
- @middleware_stack.add(Otto::Security::Authentication::AuthenticationMiddleware, @auth_config)
180
- end
181
-
182
171
  # Add a single authentication strategy
183
172
  #
173
+ # Part of the Security::Configurator facade for consolidated configuration.
174
+ # This delegates to the same storage as Otto#add_auth_strategy, allowing
175
+ # authentication to be configured alongside other security features.
176
+ #
177
+ # Prefer using Otto#add_auth_strategy directly for simpler cases, or use this
178
+ # when configuring multiple security features together via the security facade.
179
+ #
184
180
  # @param name [String] Strategy name
185
181
  # @param strategy [Otto::Security::Authentication::AuthStrategy] Strategy instance
182
+ # @example
183
+ # otto.security.add_auth_strategy('session', SessionStrategy.new)
184
+ # @raise [ArgumentError] if strategy name already registered
186
185
  def add_auth_strategy(name, strategy)
186
+ # Strict mode: Detect strategy name collisions
187
+ if @auth_config[:auth_strategies].key?(name)
188
+ raise ArgumentError, "Authentication strategy '#{name}' is already registered"
189
+ end
190
+
187
191
  @auth_config[:auth_strategies][name] = strategy
188
- enable_authentication!
189
192
  end
190
193
 
191
194
  # Configure authentication strategies for route-level access control.
@@ -196,7 +199,6 @@ class Otto
196
199
  # Merge new strategies with existing ones, preserving shared state
197
200
  @auth_config[:auth_strategies].merge!(strategies)
198
201
  @auth_config[:default_auth_strategy] = default_strategy
199
- enable_authentication! unless strategies.empty?
200
202
  end
201
203
 
202
204
  # Configure rate limiting settings.
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/csrf.rb
4
2
  #
3
+ # frozen_string_literal: true
4
+ #
5
5
  # Index file for CSRF protection components
6
6
  # Provides backward compatibility for existing CSRF usage
7
7
 
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/middleware/csrf_middleware.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../config'
@@ -27,7 +29,15 @@ class Otto
27
29
  end
28
30
 
29
31
  # Validate CSRF token for unsafe methods
30
- return csrf_error_response unless valid_csrf_token?(request)
32
+ unless valid_csrf_token?(request)
33
+ # Log CSRF validation failure
34
+ Otto.structured_log(:warn, "CSRF validation failed",
35
+ Otto::LoggingHelpers.request_context(env).merge(
36
+ referrer: request.referrer
37
+ )
38
+ )
39
+ return csrf_error_response
40
+ end
31
41
 
32
42
  @app.call(env)
33
43
  end
@@ -0,0 +1,231 @@
1
+ # lib/otto/security/middleware/ip_privacy_middleware.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Security
7
+ module Middleware
8
+ # IP Privacy Middleware
9
+ #
10
+ # Automatically masks IP addresses for privacy by default. Original IPs
11
+ # are never stored unless privacy is explicitly disabled.
12
+ #
13
+ # This middleware runs FIRST in the stack to ensure all downstream
14
+ # middleware and application code receives masked IPs by default.
15
+ #
16
+ # @example Default behavior (privacy enabled)
17
+ # # env['REMOTE_ADDR'] is masked to 192.168.1.0
18
+ # # env['otto.redacted_fingerprint'] contains full anonymized data
19
+ # # env['otto.original_ip'] is NOT set
20
+ #
21
+ # @example Privacy disabled
22
+ # otto.disable_ip_privacy!
23
+ # # env['REMOTE_ADDR'] contains real IP
24
+ # # env['otto.original_ip'] also contains real IP
25
+ #
26
+ class IPPrivacyMiddleware
27
+ # Initialize IP Privacy middleware
28
+ #
29
+ # @param app [#call] Rack application
30
+ # @param security_config [Otto::Security::Config] Security configuration
31
+ def initialize(app, security_config = nil)
32
+ @app = app
33
+ @security_config = security_config
34
+ @config = security_config&.ip_privacy_config || Otto::Privacy::Config.new
35
+
36
+ # Privacy is enabled by default unless explicitly disabled
37
+ @privacy_enabled = @config.enabled?
38
+ end
39
+
40
+ # Process request with IP privacy
41
+ #
42
+ # @param env [Hash] Rack environment
43
+ # @return [Array] Rack response tuple [status, headers, body]
44
+ def call(env)
45
+ if @privacy_enabled
46
+ apply_privacy(env)
47
+ else
48
+ apply_no_privacy(env)
49
+ end
50
+
51
+ @app.call(env)
52
+ end
53
+
54
+ private
55
+
56
+ # Apply privacy settings to environment
57
+ #
58
+ # @param env [Hash] Rack environment
59
+ # Apply privacy settings to environment
60
+ #
61
+ # @param env [Hash] Rack environment
62
+ # Apply privacy settings to environment
63
+ #
64
+ # @param env [Hash] Rack environment
65
+ def apply_privacy(env)
66
+ # Resolve the actual client IP (handling proxies)
67
+ client_ip = resolve_client_ip(env)
68
+
69
+ Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
70
+
71
+ # Skip masking for private/localhost IPs unless explicitly configured to mask them
72
+ # This provides better DX for development while still protecting public IPs
73
+ unless @config.mask_private_ips
74
+ if Otto::Privacy::IPPrivacy.private_or_localhost?(client_ip)
75
+ # Update REMOTE_ADDR to the resolved client IP (even though it's not masked)
76
+ env['REMOTE_ADDR'] = client_ip
77
+ env['otto.original_ip'] = client_ip
78
+ # Don't mask forwarded headers for private IPs
79
+ Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
80
+ return
81
+ end
82
+ end
83
+
84
+ # Create privacy-safe fingerprint using the resolved client IP
85
+ # We temporarily set REMOTE_ADDR to the client IP for fingerprint creation
86
+ original_remote_addr = env['REMOTE_ADDR']
87
+ env['REMOTE_ADDR'] = client_ip
88
+ fingerprint = Otto::Privacy::RedactedFingerprint.new(env, @config)
89
+ env['REMOTE_ADDR'] = original_remote_addr
90
+
91
+ # Set privacy-safe values in environment
92
+ env['otto.privacy.fingerprint'] = fingerprint
93
+ env['otto.privacy.masked_ip'] = fingerprint.masked_ip
94
+ env['otto.privacy.hashed_ip'] = fingerprint.hashed_ip
95
+ env['otto.privacy.geo_country'] = fingerprint.country
96
+
97
+ # CRITICAL: Replace REMOTE_ADDR and forwarded headers with masked values
98
+ # This ensures downstream code (rate limiting, auth, logging, Rack's request.ip)
99
+ # automatically uses the masked values without modification
100
+ env['REMOTE_ADDR'] = fingerprint.masked_ip
101
+
102
+ # Replace User-Agent with anonymized version (consistent with IP masking)
103
+ # CRITICAL: Always replace, even if nil, to clear original sensitive data
104
+ env['HTTP_USER_AGENT'] = fingerprint.anonymized_ua
105
+
106
+ # Replace Referer with anonymized version (query params stripped)
107
+ # CRITICAL: Always replace, even if nil, to clear original sensitive data
108
+ env['HTTP_REFERER'] = fingerprint.referer
109
+
110
+ # Mask X-Forwarded-For headers to prevent leakage
111
+ # Replace with masked IP so proxy resolution logic finds the masked IP
112
+ mask_forwarded_headers(env, fingerprint.masked_ip)
113
+
114
+ Otto.logger.debug "[IPPrivacyMiddleware] Masked IP: #{fingerprint.masked_ip}" if Otto.debug
115
+
116
+ # NOTE: We deliberately DO NOT set env['otto.original_ip'], env['otto.original_user_agent'],
117
+ # or env['otto.original_referer']. This prevents accidental leakage of the real values.
118
+ end
119
+
120
+
121
+ # Resolve the actual client IP address from the request
122
+ #
123
+ # This method handles proxy scenarios by checking X-Forwarded-For and
124
+ # other proxy headers from trusted proxies, similar to Rack's logic
125
+ # and Otto's client_ipaddress method.
126
+ #
127
+ # @param env [Hash] Rack environment
128
+ # @return [String] Resolved client IP address
129
+ def resolve_client_ip(env)
130
+ remote_addr = env['REMOTE_ADDR']
131
+
132
+ # If we don't have a security config, use direct connection
133
+ return remote_addr unless @security_config
134
+
135
+ # If REMOTE_ADDR is not from a trusted proxy, it's the client IP
136
+ return remote_addr unless trusted_proxy?(remote_addr)
137
+
138
+ # REMOTE_ADDR is from a trusted proxy, check forwarded headers
139
+ forwarded_ips = [
140
+ env['HTTP_X_FORWARDED_FOR'],
141
+ env['HTTP_X_REAL_IP'],
142
+ env['HTTP_X_CLIENT_IP'],
143
+ ].compact.map { |header| header.split(/,\s*/) }.flatten
144
+
145
+ # Return the first valid public IP from forwarded headers
146
+ forwarded_ips.each do |ip|
147
+ clean_ip = validate_ip_address(ip.strip)
148
+ next unless clean_ip
149
+
150
+ # Return first IP that's not from a trusted proxy
151
+ return clean_ip unless trusted_proxy?(clean_ip)
152
+ end
153
+
154
+ # Fallback to remote address if no valid forwarded IPs
155
+ remote_addr
156
+ end
157
+
158
+ # Mask X-Forwarded-For and related proxy headers
159
+ #
160
+ # Replaces forwarded IP headers with the masked IP to prevent leakage
161
+ # when downstream code (including Rack's request.ip) parses these headers.
162
+ #
163
+ # @param env [Hash] Rack environment
164
+ # @param masked_ip [String] The masked IP to use as replacement
165
+ def mask_forwarded_headers(env, masked_ip)
166
+ # Replace X-Forwarded-For with masked IP
167
+ # This prevents Rack::Request#ip from finding the real IP
168
+ env['HTTP_X_FORWARDED_FOR'] = masked_ip if env['HTTP_X_FORWARDED_FOR']
169
+ env['HTTP_X_REAL_IP'] = masked_ip if env['HTTP_X_REAL_IP']
170
+ env['HTTP_X_CLIENT_IP'] = masked_ip if env['HTTP_X_CLIENT_IP']
171
+
172
+ Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
173
+ end
174
+
175
+ # Check if an IP is from a trusted proxy
176
+ #
177
+ # @param ip [String] IP address to check
178
+ # @return [Boolean] true if IP is from a trusted proxy
179
+ def trusted_proxy?(ip)
180
+ return false unless @security_config
181
+
182
+ @security_config.trusted_proxy?(ip)
183
+ end
184
+
185
+ # Validate and clean IP address
186
+ #
187
+ # @param ip [String, nil] IP address to validate
188
+ # @return [String, nil] Cleaned IP or nil if invalid
189
+ def validate_ip_address(ip)
190
+ return nil if ip.nil? || ip.empty?
191
+
192
+ # Remove any port number
193
+ clean_ip = ip.split(':').first
194
+
195
+ # Basic IPv4 format validation
196
+ return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
197
+
198
+ # Validate each octet
199
+ octets = clean_ip.split('.')
200
+ return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
201
+
202
+ clean_ip
203
+ end
204
+
205
+ # Apply no-privacy settings (privacy explicitly disabled)
206
+ #
207
+ # When privacy is disabled, original IP is available for
208
+ # backward compatibility with code that requires it.
209
+ #
210
+ # @param env [Hash] Rack environment
211
+ def apply_no_privacy(env)
212
+ # Store original values for explicit access when privacy is disabled
213
+ if env['REMOTE_ADDR']
214
+ env['otto.original_ip'] = env['REMOTE_ADDR'].dup.force_encoding('UTF-8')
215
+ end
216
+
217
+ if env['HTTP_USER_AGENT']
218
+ env['otto.original_user_agent'] = env['HTTP_USER_AGENT'].dup.force_encoding('UTF-8')
219
+ end
220
+
221
+ if env['HTTP_REFERER']
222
+ env['otto.original_referer'] = env['HTTP_REFERER'].dup.force_encoding('UTF-8')
223
+ end
224
+
225
+ # env['REMOTE_ADDR'], env['HTTP_USER_AGENT'], env['HTTP_REFERER'] remain unchanged (real values)
226
+ # No fingerprint is created when privacy is disabled
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/middleware/rate_limit_middleware.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../rate_limiter'
@@ -1,3 +1,5 @@
1
+ # lib/otto/security/middleware/validation_middleware.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative '../config'
@@ -52,8 +54,21 @@ class Otto
52
54
 
53
55
  @app.call(env)
54
56
  rescue Otto::Security::ValidationError => e
57
+ # Log validation failure
58
+ Otto.structured_log(:warn, "Input validation failed",
59
+ Otto::LoggingHelpers.request_context(env).merge(
60
+ error: e.message
61
+ )
62
+ )
55
63
  validation_error_response(e.message)
56
64
  rescue Otto::Security::RequestTooLargeError => e
65
+ # Log request size violation
66
+ Otto.structured_log(:warn, "Request too large",
67
+ Otto::LoggingHelpers.request_context(env).merge(
68
+ error: e.message,
69
+ content_length: request.env['CONTENT_LENGTH']
70
+ )
71
+ )
57
72
  request_too_large_response(e.message)
58
73
  end
59
74
  end
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/rate_limiter.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  require 'json'
6
6
 
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/rate_limiting.rb
4
2
  #
3
+ # frozen_string_literal: true
4
+ #
5
5
  # Index file for rate limiting components
6
6
  # Provides backward compatibility for existing rate limiting usage
7
7
 
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/security/validator.rb
4
2
  #
3
+ # frozen_string_literal: true
4
+ #
5
5
  # Index file for validation middleware
6
6
  # Provides backward compatibility for existing validation usage
7
7
 
@@ -0,0 +1,12 @@
1
+ # lib/otto/security.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'security/authentication/strategy_result'
6
+ require_relative 'security/authorization_error'
7
+ require_relative 'security/config'
8
+ require_relative 'security/configurator'
9
+ require_relative 'security/middleware/csrf_middleware'
10
+ require_relative 'security/middleware/validation_middleware'
11
+ require_relative 'security/middleware/rate_limit_middleware'
12
+ require_relative 'security/middleware/ip_privacy_middleware'
data/lib/otto/static.rb CHANGED
@@ -1,6 +1,6 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/static.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Static response utilities for common HTTP responses
data/lib/otto/utils.rb CHANGED
@@ -1,12 +1,37 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/utils.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
6
  # Utility methods for common operations and helpers
7
7
  module Utils
8
8
  extend self
9
9
 
10
+ # @return [Time] Current time in UTC
11
+ def now
12
+ Time.now.utc
13
+ end
14
+
15
+ # Returns the current time in microseconds.
16
+ # This is used to measure the duration of Database commands.
17
+ #
18
+ # Alias: now_in_microseconds
19
+ #
20
+ # @return [Integer] The current time in microseconds.
21
+ def now_in_μs
22
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :microsecond)
23
+ end
24
+ alias now_in_microseconds now_in_μs
25
+
26
+ # Determine if a value represents a "yes" or true value
27
+ #
28
+ # @param value [Object] The value to evaluate
29
+ # @return [Boolean] True if the value represents "yes", false otherwise
30
+ #
31
+ # Examples:
32
+ # yes?('true') # => true
33
+ # yes?('yes') # => true
34
+ # yes?('1') # => true
10
35
  def yes?(value)
11
36
  !value.to_s.empty? && %w[true yes 1].include?(value.to_s.downcase)
12
37
  end
data/lib/otto/version.rb CHANGED
@@ -1,7 +1,7 @@
1
- # frozen_string_literal: true
2
-
3
1
  # lib/otto/version.rb
2
+ #
3
+ # frozen_string_literal: true
4
4
 
5
5
  class Otto
6
- VERSION = '2.0.0.pre2'
6
+ VERSION = '2.0.0.pre7'
7
7
  end