otto 2.0.0.pre1 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -3
  3. data/.github/workflows/claude-code-review.yml +30 -14
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.rubocop.yml +4 -1
  6. data/CHANGELOG.rst +54 -6
  7. data/CLAUDE.md +537 -0
  8. data/Gemfile +3 -2
  9. data/Gemfile.lock +34 -26
  10. data/benchmark_middleware_wrap.rb +163 -0
  11. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
  12. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
  13. data/docs/.gitignore +2 -0
  14. data/docs/ipaddr-encoding-quirk.md +34 -0
  15. data/docs/migrating/v2.0.0-pre2.md +338 -0
  16. data/examples/authentication_strategies/config.ru +0 -1
  17. data/lib/otto/core/configuration.rb +91 -41
  18. data/lib/otto/core/freezable.rb +93 -0
  19. data/lib/otto/core/middleware_stack.rb +103 -16
  20. data/lib/otto/core/router.rb +8 -7
  21. data/lib/otto/core.rb +8 -0
  22. data/lib/otto/env_keys.rb +118 -0
  23. data/lib/otto/helpers/base.rb +2 -21
  24. data/lib/otto/helpers/request.rb +80 -2
  25. data/lib/otto/helpers/response.rb +25 -3
  26. data/lib/otto/helpers.rb +4 -0
  27. data/lib/otto/locale/config.rb +56 -0
  28. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
  29. data/lib/otto/mcp/server.rb +26 -13
  30. data/lib/otto/mcp.rb +3 -0
  31. data/lib/otto/privacy/config.rb +199 -0
  32. data/lib/otto/privacy/geo_resolver.rb +115 -0
  33. data/lib/otto/privacy/ip_privacy.rb +175 -0
  34. data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
  35. data/lib/otto/privacy.rb +29 -0
  36. data/lib/otto/response_handlers/json.rb +6 -0
  37. data/lib/otto/route.rb +44 -48
  38. data/lib/otto/route_handlers/base.rb +1 -2
  39. data/lib/otto/route_handlers/factory.rb +24 -9
  40. data/lib/otto/route_handlers/logic_class.rb +2 -2
  41. data/lib/otto/security/authentication/auth_failure.rb +44 -0
  42. data/lib/otto/security/authentication/auth_strategy.rb +3 -3
  43. data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
  44. data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
  45. data/lib/otto/security/authentication/strategy_result.rb +129 -15
  46. data/lib/otto/security/authentication.rb +5 -6
  47. data/lib/otto/security/config.rb +51 -18
  48. data/lib/otto/security/configurator.rb +2 -15
  49. data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
  50. data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
  51. data/lib/otto/security.rb +9 -0
  52. data/lib/otto/version.rb +1 -1
  53. data/lib/otto.rb +183 -89
  54. data/otto.gemspec +5 -0
  55. metadata +83 -8
  56. data/changelog.d/20250911_235619_delano_next.rst +0 -28
  57. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
  58. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
  59. data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
  60. data/lib/otto/security/authentication/failure_result.rb +0 -36
@@ -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
@@ -7,6 +7,21 @@ class Otto
7
7
  module Middleware
8
8
  # Middleware for applying rate limiting to HTTP requests
9
9
  class RateLimitMiddleware
10
+ # NOTE: This middleware is a CONFIGURATOR, not an enforcer.
11
+ #
12
+ # Actual rate limiting is performed by Rack::Attack globally via
13
+ # configure_rack_attack!. This middleware registers during initialization
14
+ # and then passes through all requests.
15
+ #
16
+ # To enforce rate limits, Rack::Attack must be added to the middleware
17
+ # stack BEFORE Otto's router (typically done by the hosting application).
18
+ #
19
+ # Example (config.ru):
20
+ # use Rack::Attack # Must come before Otto
21
+ # run otto
22
+ #
23
+ # The call method is a pass-through; rate limiting happens in Rack::Attack.
24
+
10
25
  def initialize(app, security_config = nil)
11
26
  @app = app
12
27
  @security_config = security_config
@@ -19,10 +34,11 @@ class Otto
19
34
  end
20
35
  end
21
36
 
37
+ # Pass-through call - actual rate limiting handled by Rack::Attack
38
+ #
39
+ # This middleware does not enforce limits itself. It configures
40
+ # Rack::Attack during initialization, then delegates all requests.
22
41
  def call(env)
23
- return @app.call(env) unless @rate_limiter_available
24
-
25
- # Let rack-attack handle the rate limiting
26
42
  @app.call(env)
27
43
  end
28
44
 
@@ -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.pre1'
6
+ VERSION = '2.0.0.pre3'
7
7
  end