otto 2.2.0 → 2.3.0

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.
data/lib/otto/request.rb CHANGED
@@ -24,13 +24,25 @@ class Otto
24
24
  env['HTTP_USER_AGENT']
25
25
  end
26
26
 
27
- # NOTE: We do NOT override Rack::Request#ip
27
+ # Canonical client IP for the request.
28
28
  #
29
- # IPPrivacyMiddleware masks both REMOTE_ADDR and X-Forwarded-For headers,
30
- # so Rack's native ip resolution logic works correctly with masked values.
31
- # This allows Rack to handle proxy scenarios (trusted proxies, header parsing)
32
- # while still returning privacy-safe masked IPs.
29
+ # Prefers env['otto.client_ip'] the value resolved once, early, by
30
+ # IPPrivacyMiddleware ("resolve once, read everywhere"): the masked IP
31
+ # when privacy is enabled, or the resolved real IP when privacy is
32
+ # disabled or the address is exempt. This means downstream code no longer
33
+ # depends on REMOTE_ADDR / X-Forwarded-For rewriting being load-bearing.
33
34
  #
35
+ # Falls back to Rack's native resolution when the middleware has not run
36
+ # (e.g. standalone request use without the Otto middleware stack).
37
+ #
38
+ # @return [String, nil] Canonical (privacy-applied) client IP
39
+ def ip
40
+ canonical = env['otto.client_ip']
41
+ return canonical if canonical && !canonical.empty?
42
+
43
+ super
44
+ end
45
+
34
46
  # If you need the masked IP explicitly, use:
35
47
  # req.masked_ip # => '192.168.1.0' or nil if privacy disabled
36
48
  #
@@ -51,7 +63,7 @@ class Otto
51
63
  # fingerprint.masked_ip # => '192.168.1.0'
52
64
  # fingerprint.country # => 'US'
53
65
  def redacted_fingerprint
54
- env['otto.redacted_fingerprint']
66
+ env['otto.privacy.fingerprint']
55
67
  end
56
68
 
57
69
  # Get the geo-location country code for the request
@@ -63,7 +75,7 @@ class Otto
63
75
  # @example
64
76
  # req.geo_country # => 'US'
65
77
  def geo_country
66
- redacted_fingerprint&.country || env['otto.geo_country']
78
+ redacted_fingerprint&.country || env['otto.privacy.geo_country']
67
79
  end
68
80
 
69
81
  # Get anonymized user agent string
@@ -91,7 +103,7 @@ class Otto
91
103
  # @example
92
104
  # req.masked_ip # => '192.168.1.0'
93
105
  def masked_ip
94
- env['otto.masked_ip'] || env['REMOTE_ADDR']
106
+ env['otto.privacy.masked_ip'] || env['REMOTE_ADDR']
95
107
  end
96
108
 
97
109
  # Get hashed IP for session correlation
@@ -104,30 +116,19 @@ class Otto
104
116
  # @example
105
117
  # req.hashed_ip # => 'a3f8b2c4d5e6f7...'
106
118
  def hashed_ip
107
- redacted_fingerprint&.hashed_ip || env['otto.hashed_ip']
119
+ redacted_fingerprint&.hashed_ip || env['otto.privacy.hashed_ip']
108
120
  end
109
121
 
110
122
  def client_ipaddress
111
- remote_addr = env['REMOTE_ADDR']
112
-
113
- # If we don't have a security config or trusted proxies, use direct connection
114
- return validate_ip_address(remote_addr) if !otto_security_config || !trusted_proxy?(remote_addr)
115
-
116
- # Check forwarded headers from trusted proxies
117
- forwarded_ips = [
118
- env['HTTP_X_FORWARDED_FOR'],
119
- env['HTTP_X_REAL_IP'],
120
- env['HTTP_CLIENT_IP'],
121
- ].compact.map { |header| header.split(/,\s*/) }.flatten
122
-
123
- # Return the first valid IP that's not a private/loopback address
124
- forwarded_ips.each do |ip|
125
- clean_ip = validate_ip_address(ip.strip)
126
- return clean_ip if clean_ip && !private_ip?(clean_ip)
127
- end
128
-
129
- # Fallback to remote address
130
- validate_ip_address(remote_addr)
123
+ # Prefer the canonical client IP resolved once by IPPrivacyMiddleware
124
+ # ("resolve once, read everywhere"). Falls back to the shared resolver
125
+ # (Otto::Utils.resolve_client_ip) for standalone use without the
126
+ # middleware, so the with- and without-middleware paths agree on which
127
+ # forwarded headers to trust and how to walk a proxy chain.
128
+ canonical = env['otto.client_ip']
129
+ return canonical if canonical && !canonical.empty?
130
+
131
+ Otto::Utils.resolve_client_ip(env, otto_security_config)
131
132
  end
132
133
 
133
134
  def request_method
@@ -180,16 +181,31 @@ class Otto
180
181
  # Check direct HTTPS connection
181
182
  return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'
182
183
 
183
- remote_addr = env['REMOTE_ADDR']
184
+ # Only trust forwarded proto headers when the request actually arrived via
185
+ # a trusted proxy.
186
+ return false unless forwarded_by_trusted_proxy?
184
187
 
185
- # Only trust forwarded proto headers from trusted proxies
186
- if otto_security_config && trusted_proxy?(remote_addr)
187
- # X-Scheme is set by nginx
188
- # X-FORWARDED-PROTO is set by elastic load balancer
189
- return env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
190
- end
188
+ # X-Scheme is set by nginx; X-Forwarded-Proto by elastic load balancer
189
+ env['HTTP_X_FORWARDED_PROTO'] == 'https' || env['HTTP_X_SCHEME'] == 'https'
190
+ end
191
191
 
192
- false
192
+ # Whether the request arrived through a trusted proxy.
193
+ #
194
+ # Prefers the canonical decision recorded once by IPPrivacyMiddleware in
195
+ # env['otto.via_trusted_proxy'] — evaluated against the original peer before
196
+ # REMOTE_ADDR is masked, so it stays correct even after masking. Falls back
197
+ # to evaluating the current REMOTE_ADDR when the middleware has not run
198
+ # (standalone request use).
199
+ #
200
+ # This is the trusted-proxy *identity* check only and is independent of
201
+ # count-based depth mode: depth resolves the client IP but never grants
202
+ # proxy trust for X-Forwarded-Proto.
203
+ #
204
+ # @return [Boolean]
205
+ def forwarded_by_trusted_proxy?
206
+ return env['otto.via_trusted_proxy'] if env.key?('otto.via_trusted_proxy')
207
+
208
+ otto_security_config ? trusted_proxy?(env['REMOTE_ADDR']) : false
193
209
  end
194
210
 
195
211
  # See: http://stackoverflow.com/questions/10013812/how-to-prevent-jquery-ajax-from-following-a-redirect-after-a-post
@@ -227,41 +243,25 @@ class Otto
227
243
  end
228
244
 
229
245
  def validate_ip_address(ip)
230
- return nil if ip.nil? || ip.empty?
231
-
232
- # Remove any port number
233
- clean_ip = ip.split(':').first
234
-
235
- # Basic IP format validation
236
- return nil unless clean_ip.match?(/\A\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\z/)
237
-
238
- # Validate each octet
239
- octets = clean_ip.split('.')
240
- return nil unless octets.all? { |octet| (0..255).cover?(octet.to_i) }
241
-
242
- clean_ip
246
+ Otto::Utils.normalize_ip(ip)
243
247
  end
244
248
 
249
+ # Whether the given address is non-public (private, loopback, link-local,
250
+ # multicast or unspecified). IPv4 and IPv6 aware via Otto::Utils.private_ip?
251
+ # — the previous implementation was an IPv4-only regex that silently
252
+ # treated every IPv6 address (including ::1 and ULA fc00::/7) as public.
253
+ #
254
+ # @param ip [String, IPAddr, nil] address to classify
255
+ # @return [Boolean]
245
256
  def private_ip?(ip)
246
- return false unless ip
247
-
248
- # RFC 1918 private ranges and loopback
249
- private_ranges = [
250
- /\A10\./, # 10.0.0.0/8
251
- /\A172\.(1[6-9]|2[0-9]|3[01])\./, # 172.16.0.0/12
252
- /\A192\.168\./, # 192.168.0.0/16
253
- /\A169\.254\./, # 169.254.0.0/16 (link-local)
254
- /\A224\./, # 224.0.0.0/4 (multicast)
255
- /\A0\./, # 0.0.0.0/8
256
- ]
257
-
258
- private_ranges.any? { |range| ip.match?(range) }
257
+ Otto::Utils.private_ip?(ip)
259
258
  end
260
259
 
261
260
  def local_or_private_ip?(ip)
262
261
  return false unless ip
263
262
 
264
- # Check for localhost
263
+ # Fast path for the common localhost cases (avoids IPAddr allocation);
264
+ # private_ip? would also catch these via IPAddr#loopback?.
265
265
  return true if ['127.0.0.1', '::1'].include?(ip)
266
266
 
267
267
  # Check for private IP ranges
data/lib/otto/route.rb CHANGED
@@ -2,6 +2,8 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
+ require_relative 'security/constant_resolver'
6
+
5
7
  class Otto
6
8
  # Otto::Route
7
9
  #
@@ -51,7 +53,7 @@ class Otto
51
53
  @route_definition = Otto::RouteDefinition.new(verb, path, definition, pattern: pattern, keys: keys)
52
54
 
53
55
  # Resolve the class
54
- @klass = safe_const_get(@route_definition.klass_name)
56
+ @klass = Otto::Security::ConstantResolver.safe_const_get(@route_definition.klass_name)
55
57
  end
56
58
 
57
59
  # Delegate common methods to route_definition for backward compatibility
@@ -170,48 +172,6 @@ class Otto
170
172
 
171
173
  private
172
174
 
173
- # Safely resolve a class name using Object.const_get with security validations
174
- # This replaces the previous eval() usage to prevent code injection attacks.
175
- #
176
- # Security features:
177
- # - Validates class name format (must start with capital letter)
178
- # - Prevents access to dangerous system classes
179
- # - Blocks relative class references (starting with ::)
180
- # - Provides clear error messages for debugging
181
- #
182
- # @param class_name [String] The class name to resolve
183
- # @return [Class] The resolved class
184
- # @raise [ArgumentError] if class name is invalid, forbidden, or not found
185
- def safe_const_get(class_name)
186
- # Validate class name format
187
- unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*(?:::[A-Z][a-zA-Z0-9_]*)*\z/)
188
- raise ArgumentError, "Invalid class name format: #{class_name}"
189
- end
190
-
191
- # Remove any leading :: then add exactly one
192
- fq_class_name = "::#{class_name.sub(/^::+/, '')}"
193
-
194
- # Prevent dangerous class names
195
- forbidden_classes = %w[
196
- Kernel Module Class Object BasicObject
197
- File Dir IO Process System
198
- Binding Proc Method UnboundMethod
199
- Thread ThreadGroup Fiber
200
- ObjectSpace GC
201
- ]
202
-
203
- if forbidden_classes.include?(class_name) || class_name.start_with?('::')
204
- raise ArgumentError, "Forbidden class name: #{class_name}"
205
- end
206
-
207
- begin
208
- # Always guarantee exactly two leading colons
209
- Object.const_get(fq_class_name)
210
- rescue NameError => e
211
- raise ArgumentError, "Class not found: #{fq_class_name} - #{e.message}"
212
- end
213
- end
214
-
215
175
  def compile(path)
216
176
  keys = []
217
177
 
@@ -5,6 +5,8 @@
5
5
  require 'json'
6
6
  require 'securerandom'
7
7
 
8
+ require_relative '../security/constant_resolver'
9
+
8
10
  class Otto
9
11
  module RouteHandlers
10
12
  # Base class for all route handlers
@@ -43,7 +45,7 @@ class Otto
43
45
  # Get the target class, loading it safely
44
46
  # @return [Class] The target class
45
47
  def target_class
46
- @target_class ||= safe_const_get(route_definition.klass_name)
48
+ @target_class ||= Otto::Security::ConstantResolver.safe_const_get(route_definition.klass_name)
47
49
  end
48
50
 
49
51
  # Template method for subclasses to implement their invocation logic
@@ -187,19 +189,6 @@ class Otto
187
189
 
188
190
  handler_class.handle(result, response, context)
189
191
  end
190
-
191
- private
192
-
193
- # Safely get a constant from a string name
194
- # @param name [String] Class name
195
- # @return [Class] The class
196
- def safe_const_get(name)
197
- name.split('::').inject(Object) do |scope, const_name|
198
- scope.const_get(const_name)
199
- end
200
- rescue NameError => e
201
- raise NameError, "Unknown class: #{name} (#{e})"
202
- end
203
192
  end
204
193
  end
205
194
  end
@@ -188,7 +188,7 @@ class Otto
188
188
 
189
189
  # Build metadata for anonymous routes
190
190
  def build_anonymous_metadata(env)
191
- metadata = { ip: env['REMOTE_ADDR'] }
191
+ metadata = { ip: env['otto.client_ip'] || env['REMOTE_ADDR'] }
192
192
  metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
193
193
  metadata
194
194
  end
@@ -196,7 +196,7 @@ class Otto
196
196
  # Build metadata for failed authentication
197
197
  def build_failure_metadata(env, failed_strategies)
198
198
  metadata = {
199
- ip: env['REMOTE_ADDR'],
199
+ ip: env['otto.client_ip'] || env['REMOTE_ADDR'],
200
200
  auth_failure: 'All authentication strategies failed',
201
201
  attempted_strategies: failed_strategies.map { |f| f[:strategy] },
202
202
  failure_reasons: failed_strategies.map { |f| f[:reason] },
@@ -3,6 +3,7 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  require_relative '../auth_strategy'
6
+ require 'rack/utils'
6
7
 
7
8
  class Otto
8
9
  module Security
@@ -27,7 +28,7 @@ class Otto
27
28
 
28
29
  return failure('No API key provided') unless api_key
29
30
 
30
- if @api_keys.empty? || @api_keys.include?(api_key)
31
+ if @api_keys.empty? || valid_api_key?(api_key)
31
32
  # Create a simple user hash for API key authentication
32
33
  user_data = { api_key: api_key }
33
34
  success(user: user_data, api_key: api_key)
@@ -35,6 +36,17 @@ class Otto
35
36
  failure('Invalid API key')
36
37
  end
37
38
  end
39
+
40
+ private
41
+
42
+ # Constant-time membership check over the configured API keys. Compares
43
+ # against every key without short-circuiting so match position/membership is
44
+ # not leaked via timing.
45
+ def valid_api_key?(api_key)
46
+ @api_keys.reduce(false) do |matched, key|
47
+ Rack::Utils.secure_compare(key, api_key) || matched
48
+ end
49
+ end
38
50
  end
39
51
  end
40
52
  end
@@ -12,9 +12,11 @@ class Otto
12
12
  # Public access strategy - always allows access
13
13
  class NoAuthStrategy < AuthStrategy
14
14
  def authenticate(env, _requirement)
15
- # Note: env['REMOTE_ADDR'] is masked by IPPrivacyMiddleware by default
16
- metadata = { ip: env['REMOTE_ADDR'] }
17
- metadata[:country] = env['otto.geo_country'] if env['otto.geo_country']
15
+ # Canonical client IP ("resolve once, read everywhere"): masked by
16
+ # IPPrivacyMiddleware when privacy is on; REMOTE_ADDR fallback when
17
+ # the middleware has not run.
18
+ metadata = { ip: env['otto.client_ip'] || env['REMOTE_ADDR'] }
19
+ metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
18
20
 
19
21
  Otto::Security::Authentication::StrategyResult.anonymous(metadata: metadata)
20
22
  end