otto 2.6.0 → 2.7.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +218 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +12 -10
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/simple_geo_resolver.rb +38 -5
  20. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  21. data/lib/otto/core/middleware_stack.rb +72 -25
  22. data/lib/otto/env_keys.rb +32 -0
  23. data/lib/otto/logging_helpers.rb +50 -1
  24. data/lib/otto/mcp/rate_limiting.rb +5 -2
  25. data/lib/otto/privacy/config.rb +245 -3
  26. data/lib/otto/privacy/core.rb +93 -14
  27. data/lib/otto/privacy/geo_resolver.rb +228 -128
  28. data/lib/otto/privacy/ip_privacy.rb +24 -0
  29. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  30. data/lib/otto/privacy.rb +3 -1
  31. data/lib/otto/request.rb +8 -1
  32. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  33. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  34. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  36. data/lib/otto/security/config.rb +23 -1
  37. data/lib/otto/security/core.rb +4 -1
  38. data/lib/otto/security/csp/report_middleware.rb +3 -1
  39. data/lib/otto/security/middleware/ip_privacy_middleware.rb +201 -12
  40. data/lib/otto/security/rate_limiter.rb +7 -1
  41. data/lib/otto/utils.rb +100 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +11 -3
  44. metadata +6 -6
@@ -2,16 +2,33 @@
2
2
  #
3
3
  # frozen_string_literal: true
4
4
 
5
- require 'ipaddr'
6
-
7
5
  class Otto
8
6
  module Privacy
9
7
  # Lightweight geo-location resolution for IP addresses
10
8
  #
11
- # Provides country-level geo-location without requiring external
12
- # databases or API calls. Supports headers from major CDN/infrastructure
13
- # providers (Cloudflare, AWS CloudFront, Fastly, Akamai, Azure) with
14
- # fallback to basic IP range detection.
9
+ # Provides country-level geo-location. Headers from major CDN/infrastructure
10
+ # providers are checked first; an optional local MaxMind-format (.mmdb)
11
+ # database gives an offline fallback that operates on Otto's already-MASKED
12
+ # IP (no external API calls, and the unmasked address never reaches the
13
+ # resolver).
14
+ #
15
+ # Resolution order (first hit wins), when a privacy Config is supplied:
16
+ # 1. App-configured trusted header (Config#geo_header), e.g. 'X-Client-Country'
17
+ # 2. Built-in CDN/infrastructure provider headers (see below)
18
+ # 3. Custom resolver hook ({.custom_resolver})
19
+ # 4. Local MMDB lookup, masked before lookup (Config#geo_db_reader)
20
+ # 5. '**' (unknown)
21
+ #
22
+ # Steps 1 and 2 are SKIPPED when the request's geo headers are not trusted.
23
+ # Every geo header is client-spoofable unless you are actually behind the CDN
24
+ # that sets it, so the middleware trusts them only for a request that arrived
25
+ # via a configured trusted proxy; otherwise resolution falls straight to the
26
+ # custom resolver / database.
27
+ #
28
+ # Resolution is HONEST: when no header, custom resolver, or database resolves
29
+ # a country, the answer is '**' (unknown). Otto does not guess from a
30
+ # hardcoded IP-range table — configure a database or an edge header for real
31
+ # geo-location.
15
32
  #
16
33
  # Supported CDN/Infrastructure Headers:
17
34
  # - Cloudflare: CF-IPCountry
@@ -19,6 +36,7 @@ class Otto
19
36
  # - Fastly: Fastly-Client-IP-Country
20
37
  # - Akamai: X-Akamai-Edgescape (country_code=XX format)
21
38
  # - Azure Front Door: X-Azure-ClientIP-Country
39
+ # - Vercel: X-Vercel-IP-Country
22
40
  # - Semi-standard: X-Geo-Country, X-Country-Code, Country-Code
23
41
  #
24
42
  # @example Resolve country from Cloudflare header
@@ -31,9 +49,9 @@ class Otto
31
49
  # GeoResolver.resolve('1.2.3.4', env)
32
50
  # # => 'GB'
33
51
  #
34
- # @example Resolve without CDN headers
52
+ # @example Resolve without any header, resolver, or database
35
53
  # GeoResolver.resolve('8.8.8.8', {})
36
- # # => 'US' (Google DNS via range detection)
54
+ # # => '**' (unknown Otto does not guess)
37
55
  #
38
56
  # @example Using a custom resolver (MaxMind)
39
57
  # GeoResolver.custom_resolver = ->(ip, env) {
@@ -45,7 +63,7 @@ class Otto
45
63
  #
46
64
  # @example Extending via subclass
47
65
  # class MyGeoResolver < Otto::Privacy::GeoResolver
48
- # def self.detect_by_range(ip)
66
+ # def self.check_geo_database(ip, config)
49
67
  # # Custom logic here
50
68
  # super # Fall back to parent
51
69
  # end
@@ -54,14 +72,16 @@ class Otto
54
72
  #
55
73
  # Resolution flow
56
74
  #
57
- # Request → Has familiar HTTP Header?
58
- # ├─ Yes → Return country (Cloudflare, AWS, etc.)
59
- # └─ NoCustom Resolver?
60
- # ├─ Configured Call & validate
61
- # │ ├─ Valid Return country
62
- # │ └─ Invalid/ErrorContinue
63
- # └─ Not configured Built-in range detection
64
- # └─ Unknown ('**')
75
+ # Request → Headers trusted?
76
+ # ├─ Yes → Config#geo_header set & valid? → Return country
77
+ # └─ Provider header present & valid? Return country
78
+ # └─ (headers skipped when not trusted)
79
+ # Custom Resolver configured?
80
+ # ├─ ValidReturn country
81
+ # └─ Invalid/ErrorContinue
82
+ # Local MMDB reader configured?
83
+ # ├─ Hit → Return country
84
+ # └─ Miss → Unknown ('**')
65
85
  #
66
86
  class GeoResolver
67
87
  # Unknown country code (not ISO 3166-1 alpha-2, intentionally distinct)
@@ -114,40 +134,150 @@ class Otto
114
134
  end
115
135
  end
116
136
 
117
- # Resolve country code for an IP address
137
+ # Resolve country code for an IP address.
138
+ #
139
+ # Resolution order (first hit wins). Header steps (1–2) are skipped when
140
+ # +headers_trusted+ is false, since geo headers are client-spoofable
141
+ # unless the request actually arrived through the trusted CDN/proxy:
142
+ # 1. App-configured trusted header (+config.geo_header+)
143
+ # 2. Built-in CDN/infrastructure provider headers
144
+ # 3. Custom resolver hook ({.custom_resolver})
145
+ # 4. Local MMDB lookup (+config.geo_db_reader+), masked before lookup
146
+ # 5. '**' for unknown (no guessing)
118
147
  #
119
- # Resolution priority:
120
- # 1. CDN/infrastructure provider headers (Cloudflare, AWS, Fastly, etc.)
121
- # 2. Basic IP range detection for major countries/providers
122
- # 3. Return '**' for unknown
148
+ # Country-level MMDB networks are almost always >= /24, so a /24-masked
149
+ # +x.y.z.0+ resolves to the same country as the real IP. The database
150
+ # lookup masks internally, and the Otto middleware additionally hands this
151
+ # method a masked IP and a masked env, so neither the database nor a custom
152
+ # resolver ever sees the unmasked address.
123
153
  #
124
- # @param ip [String] IP address to resolve
125
- # @param env [Hash] Rack environment (may contain geo headers)
154
+ # @param ip [String] IP address to resolve. When called from the Otto
155
+ # middleware this is already the masked IP; the database lookup masks
156
+ # again internally, so a direct caller passing a real IP still never
157
+ # exposes the unmasked address to the database.
158
+ # @param env [Hash] Rack environment (may contain geo headers). In the
159
+ # framework path this is a masked view (REMOTE_ADDR/forwarded headers
160
+ # masked), so a custom resolver never sees the raw IP through env either.
161
+ # @param config [Otto::Privacy::Config, nil] privacy config supplying the
162
+ # configured header and MMDB reader. When nil, only the built-in provider
163
+ # headers and the custom resolver are consulted.
164
+ # @param headers_trusted [Boolean] whether request geo headers may be
165
+ # trusted for this request (default true; the middleware computes this
166
+ # from the trusted-proxy decision).
126
167
  # @return [String] ISO 3166-1 alpha-2 country code or '**'
127
- def self.resolve(ip, env = {})
168
+ def self.resolve(ip, env = {}, config = nil, headers_trusted: true)
128
169
  return UNKNOWN if ip.nil? || ip.empty?
129
170
 
130
- # Check CDN/infrastructure headers in priority order
131
- # Priority based on reliability and deployment frequency
132
- country = check_geo_headers(env)
133
- return country if country
171
+ # Resolution is honest: when no header, custom resolver, or database
172
+ # resolves a country, the answer is '**' (unknown) — never a guess.
173
+ resolve_from_sources(ip, env, config, headers_trusted) || UNKNOWN
174
+ end
134
175
 
135
- # Try custom resolver if configured
136
- if @custom_resolver
137
- begin
138
- country = @custom_resolver.call(ip, env)
139
- return country if country && valid_country_code?(country)
140
- rescue StandardError => e
141
- # Log error but don't crash - fall through to built-in detection
142
- warn "GeoResolver custom resolver error: #{e.message}" if $DEBUG
143
- end
176
+ # Walk the ordered resolution sources and return the first country found.
177
+ #
178
+ # @return [String, nil] country code, or nil if no source resolved
179
+ # @api private
180
+ def self.resolve_from_sources(ip, env, config, headers_trusted)
181
+ if headers_trusted
182
+ # 1. Configured header wins over 2. built-in provider headers.
183
+ country = check_configured_header(env, config) || check_geo_headers(env)
184
+ return country if country
144
185
  end
145
186
 
146
- # Fallback: Basic range detection
147
- detect_by_range(ip)
148
- rescue IPAddr::InvalidAddressError
149
- UNKNOWN
187
+ # 3. Custom resolver hook, then 4. local MMDB lookup.
188
+ check_custom_resolver(ip, env) || check_geo_database(ip, config)
189
+ end
190
+ private_class_method :resolve_from_sources
191
+
192
+ # Check the app-configured trusted geo header, if any.
193
+ #
194
+ # @param env [Hash] Rack environment
195
+ # @param config [Otto::Privacy::Config, nil]
196
+ # @return [String, nil] valid country code from the configured header, or nil
197
+ # @api private
198
+ def self.check_configured_header(env, config)
199
+ header = config&.geo_header
200
+ return nil unless header
201
+
202
+ country = env[header]
203
+ valid_country_code?(country) ? country : nil
204
+ end
205
+ private_class_method :check_configured_header
206
+
207
+ # Invoke the configured custom resolver, guarding against errors.
208
+ #
209
+ # @param ip [String] IP address handed to the resolver (masked in the
210
+ # framework path — see {.resolve})
211
+ # @param env [Hash] Rack environment (masked view in the framework path)
212
+ # @return [String, nil] a valid country code, or nil
213
+ # @api private
214
+ def self.check_custom_resolver(ip, env)
215
+ resolver = @custom_resolver
216
+ return nil unless resolver
217
+
218
+ country = resolver.call(ip, env)
219
+ country if country && valid_country_code?(country)
220
+ rescue StandardError => e
221
+ # A custom resolver must never crash a request; fall through.
222
+ warn "GeoResolver custom resolver error: #{e.message}" if $DEBUG
223
+ nil
150
224
  end
225
+ private_class_method :check_custom_resolver
226
+
227
+ # Look up the country for an IP in the configured MMDB database.
228
+ #
229
+ # No-op (returns nil) when no config or reader is available. The IP is
230
+ # masked (with the config's octet precision) before the lookup so the
231
+ # unmasked address never reaches the database; masking is idempotent, so
232
+ # an already-masked IP is unaffected. Country-level networks are >= /24,
233
+ # so a /24-masked address resolves to the same country as the real one.
234
+ #
235
+ # The reader is any object responding to +#get(ip)+; result shapes from
236
+ # both GeoLite2-Country-compatible ('country' => {'iso_code' => ...}) and
237
+ # flat ('country_code' => ...) mmdb builds are handled.
238
+ #
239
+ # @param ip [String] IP address (masked internally before lookup)
240
+ # @param config [Otto::Privacy::Config, nil]
241
+ # @return [String, nil] valid country code, or nil on miss/error
242
+ # @api private
243
+ def self.check_geo_database(ip, config)
244
+ reader = config&.geo_db_reader
245
+ return nil unless reader
246
+
247
+ lookup_ip = IPPrivacy.mask_ip(ip, config.octet_precision) || ip
248
+ country = extract_db_country(reader.get(lookup_ip))
249
+ valid_country_code?(country) ? country : nil
250
+ rescue StandardError => e
251
+ # A DB read must never crash a request; fall through.
252
+ warn "GeoResolver database lookup error: #{e.message}" if $DEBUG
253
+ nil
254
+ end
255
+ private_class_method :check_geo_database
256
+
257
+ # Extract an ISO country code from an MMDB lookup result.
258
+ #
259
+ # Handles the shapes country databases actually use: GeoLite2-Country
260
+ # style ('country' => {'iso_code' => 'US'}), the flat 'country_code' =>
261
+ # 'US', and a bare 'country' => 'US'. The nested case is checked with an
262
+ # explicit Hash guard rather than Hash#dig so a bare-String 'country'
263
+ # value does not raise (String has no #dig).
264
+ #
265
+ # @param result [Object] whatever the reader returned for the IP
266
+ # @return [String, nil] country code string, or nil
267
+ # @api private
268
+ def self.extract_db_country(result)
269
+ return nil unless result.is_a?(Hash)
270
+
271
+ country = result['country']
272
+ code =
273
+ if country.is_a?(Hash)
274
+ country['iso_code']
275
+ else
276
+ result['country_code'] || country
277
+ end
278
+ code.is_a?(String) ? code : nil
279
+ end
280
+ private_class_method :extract_db_country
151
281
 
152
282
  # Check CDN/infrastructure provider geo headers
153
283
  #
@@ -157,47 +287,70 @@ class Otto
157
287
  # 3. Fastly (Fastly-Client-IP-Country)
158
288
  # 4. Akamai (X-Akamai-Edgescape) - Complex format, extract country
159
289
  # 5. Azure Front Door (X-Azure-ClientIP-Country)
160
- # 6. Semi-standard headers (X-Geo-Country, X-Country-Code, Country-Code)
290
+ # 6. Vercel (X-Vercel-IP-Country)
291
+ # 7. Semi-standard headers (X-Geo-Country, X-Country-Code, Country-Code)
292
+ #
293
+ # Simple provider headers whose value is the country code directly, checked
294
+ # ahead of Akamai (whose value is a compound Edgescape string). Cloudflare
295
+ # first (most widely deployed), then AWS CloudFront, then Fastly.
296
+ PRIMARY_COUNTRY_HEADERS = %w[
297
+ HTTP_CF_IPCOUNTRY
298
+ HTTP_CLOUDFRONT_VIEWER_COUNTRY
299
+ HTTP_FASTLY_CLIENT_IP_COUNTRY
300
+ ].freeze
301
+
302
+ # Remaining direct country-code headers, checked after Akamai: Azure Front
303
+ # Door, Vercel, then the least-reliable semi-standard headers.
304
+ SECONDARY_COUNTRY_HEADERS = %w[
305
+ HTTP_X_AZURE_CLIENTIP_COUNTRY
306
+ HTTP_X_VERCEL_IP_COUNTRY
307
+ HTTP_X_GEO_COUNTRY
308
+ HTTP_X_COUNTRY_CODE
309
+ HTTP_COUNTRY_CODE
310
+ ].freeze
311
+
312
+ # Check CDN/infrastructure provider geo headers, in priority order:
313
+ # Cloudflare, AWS CloudFront, Fastly, Akamai, Azure, Vercel, then the
314
+ # semi-standard headers.
161
315
  #
162
316
  # @param env [Hash] Rack environment
163
317
  # @return [String, nil] ISO 3166-1 alpha-2 country code or nil
164
318
  # @api private
165
319
  def self.check_geo_headers(env)
166
- # Cloudflare (most common)
167
- country = env['HTTP_CF_IPCOUNTRY']
168
- return country if valid_country_code?(country)
169
-
170
- # AWS CloudFront
171
- country = env['HTTP_CLOUDFRONT_VIEWER_COUNTRY']
172
- return country if valid_country_code?(country)
173
-
174
- # Fastly
175
- country = env['HTTP_FASTLY_CLIENT_IP_COUNTRY']
176
- return country if valid_country_code?(country)
320
+ first_valid_country(env, PRIMARY_COUNTRY_HEADERS) ||
321
+ akamai_country(env) ||
322
+ first_valid_country(env, SECONDARY_COUNTRY_HEADERS)
323
+ end
324
+ private_class_method :check_geo_headers
177
325
 
178
- # Akamai Edgescape (format: country_code=US,region_code=CA,...)
179
- if (edgescape = env['HTTP_X_AKAMAI_EDGESCAPE'])
180
- country = extract_akamai_country(edgescape)
326
+ # First valid country code among the given env header keys, or nil.
327
+ #
328
+ # @param env [Hash] Rack environment
329
+ # @param keys [Array<String>] env keys to check in order
330
+ # @return [String, nil]
331
+ # @api private
332
+ def self.first_valid_country(env, keys)
333
+ keys.each do |key|
334
+ country = env[key]
181
335
  return country if valid_country_code?(country)
182
336
  end
337
+ nil
338
+ end
339
+ private_class_method :first_valid_country
183
340
 
184
- # Azure Front Door
185
- country = env['HTTP_X_AZURE_CLIENTIP_COUNTRY']
186
- return country if valid_country_code?(country)
187
-
188
- # Semi-standard headers (least reliable, check last)
189
- country = env['HTTP_X_GEO_COUNTRY']
190
- return country if valid_country_code?(country)
191
-
192
- country = env['HTTP_X_COUNTRY_CODE']
193
- return country if valid_country_code?(country)
194
-
195
- country = env['HTTP_COUNTRY_CODE']
196
- return country if valid_country_code?(country)
341
+ # Country code from the Akamai Edgescape header, if present and valid.
342
+ #
343
+ # @param env [Hash] Rack environment
344
+ # @return [String, nil]
345
+ # @api private
346
+ def self.akamai_country(env)
347
+ edgescape = env['HTTP_X_AKAMAI_EDGESCAPE']
348
+ return nil unless edgescape
197
349
 
198
- nil
350
+ country = extract_akamai_country(edgescape)
351
+ valid_country_code?(country) ? country : nil
199
352
  end
200
- private_class_method :check_geo_headers
353
+ private_class_method :akamai_country
201
354
 
202
355
  # Extract country code from Akamai Edgescape header
203
356
  #
@@ -216,68 +369,15 @@ class Otto
216
369
  end
217
370
  private_class_method :extract_akamai_country
218
371
 
219
- # Detect country by IP range (basic implementation)
220
- #
221
- # Detects major cloud providers and well-known IP ranges.
222
- # This is intentionally limited - for comprehensive geo-location,
223
- # use CDN headers or configure a custom resolver.
224
- #
225
- # @param ip [String] IP address
226
- # @return [String] Country code or '**'
227
- # @api private
228
- def self.detect_by_range(ip)
229
- addr = IPAddr.new(ip)
230
-
231
- # Private/local addresses
232
- return UNKNOWN if IPPrivacy.private_or_localhost?(ip)
233
-
234
- # Check against known ranges
235
- KNOWN_RANGES.each do |range, country|
236
- return country if range.include?(addr)
237
- end
238
-
239
- UNKNOWN
240
- end
241
- private_class_method :detect_by_range
242
-
243
- # Known IP ranges for major providers (limited set for basic detection)
244
- # For comprehensive geo-location, use CDN headers or custom resolver
245
- KNOWN_RANGES = {
246
- # Google Public DNS
247
- IPAddr.new('8.8.8.0/24') => 'US',
248
- IPAddr.new('8.8.4.0/24') => 'US',
249
-
250
- # Cloudflare DNS
251
- IPAddr.new('1.1.1.0/24') => 'US',
252
- IPAddr.new('1.0.0.0/24') => 'US',
253
-
254
- # AWS US-East
255
- IPAddr.new('52.0.0.0/11') => 'US',
256
- IPAddr.new('54.0.0.0/8') => 'US',
257
-
258
- # AWS EU-West
259
- IPAddr.new('34.240.0.0/13') => 'IE',
260
- IPAddr.new('52.16.0.0/14') => 'IE',
261
-
262
- # AWS AP-Southeast
263
- IPAddr.new('13.210.0.0/15') => 'AU',
264
- IPAddr.new('52.62.0.0/15') => 'AU',
265
-
266
- # Quad9 DNS (Switzerland)
267
- IPAddr.new('9.9.9.0/24') => 'CH',
268
-
269
- # OpenDNS
270
- IPAddr.new('208.67.222.0/24') => 'US',
271
- IPAddr.new('208.67.220.0/24') => 'US',
272
- }.freeze
273
-
274
372
  # Validate country code format
275
373
  #
276
374
  # @param code [String] Country code to validate
277
375
  # @return [Boolean] true if valid ISO 3166-1 alpha-2 code
278
376
  # @api private
279
377
  def self.valid_country_code?(code)
280
- code.is_a?(String) && code.length == 2 && code.match?(/^[A-Z]{2}$/)
378
+ # \A / \z (string anchors), not ^ / $ (line anchors), so an embedded
379
+ # newline can never satisfy the pattern.
380
+ code.is_a?(String) && code.length == 2 && code.match?(/\A[A-Z]{2}\z/)
281
381
  end
282
382
  private_class_method :valid_country_code?
283
383
  end
@@ -119,6 +119,30 @@ class Otto
119
119
  false
120
120
  end
121
121
 
122
+ # Redact the client identifier(s) in an RFC 7239 Forwarded header value.
123
+ #
124
+ # Only the `for=` node value is replaced with the masked IP; `proto=`,
125
+ # `host=`, `by=` and the overall structure are preserved so downstream
126
+ # scheme/host/proxy decisions (absolute URLs, redirects, secure cookies)
127
+ # still work. Every `for=` element in the chain is redacted. IPv6 is
128
+ # bracketed and quoted as the RFC requires. This is the structured-header
129
+ # counterpart to the wholesale X-Forwarded-For swap.
130
+ #
131
+ # @param value [String, nil] the Forwarded header value
132
+ # @param masked_ip [String, nil] the masked client IP
133
+ # @return [String, nil] the header with every `for=` value redacted, or
134
+ # the value unchanged when there is nothing to mask
135
+ def self.mask_forwarded_for(value, masked_ip)
136
+ return value if value.nil? || masked_ip.nil? || masked_ip.empty?
137
+
138
+ replacement = masked_ip.include?(':') ? %("[#{masked_ip}]") : masked_ip
139
+ # Match `for=` only at an element/pair boundary (start, comma, or
140
+ # semicolon) so a parameter merely ending in "for" is never touched.
141
+ value.gsub(/(\A|[,;]\s*)for\s*=\s*("[^"]*"|[^;,]+)/i) do
142
+ "#{Regexp.last_match(1)}for=#{replacement}"
143
+ end
144
+ end
145
+
122
146
  # Mask IPv4 address
123
147
  #
124
148
  # @param addr [IPAddr] IPAddr object (must be IPv4)
@@ -25,18 +25,41 @@ class Otto
25
25
  :country, :anonymized_ua, :request_path,
26
26
  :request_method, :referer
27
27
 
28
+ # IP-bearing forwarded headers overwritten with the masked IP in the
29
+ # geo-resolution env view. Mirrors the set
30
+ # IPPrivacyMiddleware#mask_forwarded_headers rewrites, so a custom resolver
31
+ # reading env sees masked values everywhere the middleware would. The
32
+ # structured RFC 7239 Forwarded header (HTTP_FORWARDED) is handled
33
+ # separately in {#geo_env} (dropped, not swapped, to keep valid syntax).
34
+ GEO_MASKED_FORWARDED_HEADERS = %w[
35
+ HTTP_X_FORWARDED_FOR
36
+ HTTP_X_REAL_IP
37
+ HTTP_X_CLIENT_IP
38
+ ].freeze
39
+
28
40
  # Create a new RedactedFingerprint from a Rack environment
29
41
  #
30
42
  # @param env [Hash] Rack environment hash
31
43
  # @param config [Otto::Privacy::Config] Privacy configuration
32
- def initialize(env, config)
44
+ # @param geo_headers_trusted [Boolean] whether request geo headers may be
45
+ # trusted for this request. The middleware passes false for a
46
+ # non-trusted-proxy request when trusted proxies are configured, so
47
+ # spoofed geo headers are ignored. Defaults to true for standalone use.
48
+ def initialize(env, config, geo_headers_trusted: true)
33
49
  remote_ip = env['REMOTE_ADDR']
34
50
 
35
51
  @session_id = SecureRandom.uuid
36
52
  @timestamp = Time.now.utc
37
53
  @masked_ip = IPPrivacy.mask_ip(remote_ip, config.octet_precision)
38
54
  @hashed_ip = IPPrivacy.hash_ip(remote_ip, config.rotation_key)
39
- @country = config.geo_enabled ? GeoResolver.resolve(remote_ip, env) : nil
55
+ # hashed_ip is computed above from the real IP; geo resolution then runs
56
+ # against a MASKED view — the masked IP AND an env with the IP-bearing
57
+ # headers masked — so neither a custom resolver nor the database can see
58
+ # the unmasked address, via the argument or via env. Country-level
59
+ # networks are >= /24, so the /24-masked IP resolves to the same country.
60
+ @country = if config.geo_enabled
61
+ GeoResolver.resolve(@masked_ip, geo_env(env), config, headers_trusted: geo_headers_trusted)
62
+ end
40
63
  @anonymized_ua = anonymize_user_agent(env['HTTP_USER_AGENT'])
41
64
  @request_path = env['PATH_INFO']
42
65
  @request_method = env['REQUEST_METHOD']
@@ -90,6 +113,35 @@ class Otto
90
113
 
91
114
  private
92
115
 
116
+ # A shallow copy of env with the client-IP fields masked, for geo
117
+ # resolution. Country-level geo needs nothing finer than the masked /24,
118
+ # so a custom resolver (arbitrary app code that might log or forward what
119
+ # it receives) is handed only the masked address here — never the raw host
120
+ # IP that env['REMOTE_ADDR'] and the forwarded headers still carry at this
121
+ # point in the middleware. Non-IP keys (including geo headers like
122
+ # CF-IPCountry) are preserved. Returns env unchanged when there is no
123
+ # masked IP (nothing to hide, and geo resolution short-circuits anyway).
124
+ #
125
+ # @param env [Hash] Rack environment
126
+ # @return [Hash] masked env view
127
+ def geo_env(env)
128
+ return env if @masked_ip.nil?
129
+
130
+ masked = env.dup
131
+ masked['REMOTE_ADDR'] = @masked_ip
132
+ GEO_MASKED_FORWARDED_HEADERS.each do |key|
133
+ masked[key] = @masked_ip if masked.key?(key)
134
+ end
135
+ # HTTP_FORWARDED (RFC 7239) carries the client IP in a structured `for=`
136
+ # token — and Otto reads it as an authoritative client-IP source in
137
+ # depth mode with trusted_proxy_header 'Forwarded'/'Both'. A wholesale
138
+ # swap would produce invalid Forwarded syntax, and geo resolution needs
139
+ # nothing from it, so drop it from the geo view entirely rather than
140
+ # leak the raw address to a custom resolver.
141
+ masked.delete('HTTP_FORWARDED')
142
+ masked
143
+ end
144
+
93
145
  # Anonymize user agent string by removing version numbers and build identifiers
94
146
  #
95
147
  # Delegates to the public {UserAgentPrivacy.anonymize} so there is a single
data/lib/otto/privacy.rb CHANGED
@@ -18,7 +18,9 @@ require_relative 'privacy/redacted_fingerprint'
18
18
  # Features:
19
19
  # - Configurable IP masking (1 or 2 octets for IPv4, 80 or 96 bits for IPv6)
20
20
  # - Daily-rotating IP hashing for session correlation without tracking
21
- # - Geo-location resolution (country-level only, via CloudFlare headers)
21
+ # - Geo-location resolution (country-level only): a configurable trusted header,
22
+ # built-in CDN provider headers, an optional local MaxMind-format (.mmdb)
23
+ # database looked up on the masked IP, or a custom resolver
22
24
  # - User agent anonymization (removes version numbers)
23
25
  #
24
26
  # Privacy is ENABLED BY DEFAULT. To disable:
data/lib/otto/request.rb CHANGED
@@ -222,8 +222,15 @@ class Otto
222
222
  # Check direct HTTPS connection
223
223
  return true if env['HTTPS'] == 'on' || env['SERVER_PORT'] == '443'
224
224
 
225
+ # rack.url_scheme is server-/middleware-set (never a client header), so a
226
+ # scheme normalized upstream the canonical Rack way counts as authoritative
227
+ # — keeping this answer aligned with Rack::Request#scheme, which the
228
+ # session Secure-cookie gate and CSRF middleware read.
229
+ return true if env['rack.url_scheme'] == 'https'
230
+
225
231
  # Only trust forwarded proto headers when the request actually arrived via
226
- # a trusted proxy.
232
+ # a trusted proxy. Stricter than Rack::Request#scheme, which honors
233
+ # X-Forwarded-Proto unconditionally.
227
234
  return false unless forwarded_by_trusted_proxy?
228
235
 
229
236
  # X-Scheme is set by nginx; X-Forwarded-Proto by elastic load balancer
@@ -6,10 +6,44 @@ class Otto
6
6
  module Security
7
7
  module Authentication
8
8
  # Failure result for authentication failures
9
- AuthFailure = Data.define(:failure_reason, :auth_method) do
9
+ AuthFailure = Data.define(:failure_reason, :auth_method, :terminal) do
10
10
  # AuthFailure represents authentication failure
11
11
  # Returned by strategies when authentication fails
12
12
  # Contains failure reason for error messages
13
+ #
14
+ # TERMINAL FAILURES
15
+ # -----------------
16
+ # By default a failure is non-terminal: RouteAuthWrapper records it and
17
+ # consults the next strategy in the route's chain (OR logic), so a
18
+ # request without credentials can still fall through to an
19
+ # anonymous-capable strategy like noauth.
20
+ #
21
+ # A failure constructed with `terminal: true` means "this request
22
+ # explicitly presented credentials and they were examined and
23
+ # rejected — do not consult further strategies." RouteAuthWrapper
24
+ # halts the chain and renders the 401 with this failure's reason,
25
+ # regardless of where the strategy sits in the chain. This lets mixed
26
+ # credentialed/anonymous chains (e.g. auth=basicauth,noauth) fail
27
+ # closed on invalid credentials instead of silently degrading to
28
+ # anonymous.
29
+ #
30
+ # Only reject terminally when credentials were EXPLICITLY presented
31
+ # (e.g. an Authorization header). Ambient credentials such as session
32
+ # cookies should fail non-terminally so a logged-out browser can still
33
+ # degrade to anonymous on noauth-capable routes.
34
+
35
+ # terminal defaults to false so existing keyword construction
36
+ # (failure_reason:, auth_method:) is unaffected.
37
+ def initialize(failure_reason:, auth_method:, terminal: false)
38
+ super
39
+ end
40
+
41
+ # Check if this failure halts the strategy chain
42
+ #
43
+ # @return [Boolean] True when the chain must fail closed
44
+ def terminal?
45
+ terminal
46
+ end
13
47
 
14
48
  # Check if authenticated - always false for failures
15
49
  #
@@ -36,7 +70,7 @@ class Otto
36
70
  #
37
71
  # @return [String] Debug representation
38
72
  def inspect
39
- "#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
73
+ "#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}#{' terminal' if terminal}>"
40
74
  end
41
75
  end
42
76
  end
@@ -42,11 +42,21 @@ class Otto
42
42
  # Use for a missing, invalid, or expired credential. RouteAuthWrapper maps
43
43
  # this to 401 Unauthorized. For a VALID credential that is not permitted,
44
44
  # use #authorization_failure instead (403 Forbidden).
45
- def failure(reason = nil)
45
+ #
46
+ # Pass terminal: true when the request EXPLICITLY presented credentials
47
+ # (e.g. an Authorization header) that were examined and rejected.
48
+ # RouteAuthWrapper then halts the strategy chain and fails closed with
49
+ # 401 instead of consulting further strategies — so an anonymous-capable
50
+ # strategy later (or earlier) in the chain cannot silently accept the
51
+ # request as anonymous. Leave it false for missing credentials and for
52
+ # ambient credentials (session cookies), which should keep today's OR
53
+ # fallthrough. See AuthFailure.
54
+ def failure(reason = nil, terminal: false)
46
55
  Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
47
56
  Otto::Security::Authentication::AuthFailure.new(
48
57
  failure_reason: reason || 'Authentication failed',
49
- auth_method: strategy_auth_method
58
+ auth_method: strategy_auth_method,
59
+ terminal: terminal
50
60
  )
51
61
  end
52
62