otto 2.5.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 (67) 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 +283 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +14 -12
  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/advanced_routes/README.md +49 -0
  20. data/examples/advanced_routes/config.rb +15 -2
  21. data/examples/advanced_routes/routes +12 -0
  22. data/examples/lambda_handlers/README.md +128 -0
  23. data/examples/lambda_handlers/config.ru +26 -0
  24. data/examples/lambda_handlers/handlers.rb +75 -0
  25. data/examples/lambda_handlers/routes +28 -0
  26. data/examples/simple_geo_resolver.rb +38 -5
  27. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  28. data/lib/otto/core/configuration.rb +103 -1
  29. data/lib/otto/core/middleware_stack.rb +72 -25
  30. data/lib/otto/core/router.rb +67 -10
  31. data/lib/otto/core/uri_generator.rb +36 -2
  32. data/lib/otto/env_keys.rb +43 -0
  33. data/lib/otto/errors.rb +7 -0
  34. data/lib/otto/logging_helpers.rb +50 -1
  35. data/lib/otto/mcp/rate_limiting.rb +5 -2
  36. data/lib/otto/mcp/route_parser.rb +15 -4
  37. data/lib/otto/privacy/config.rb +281 -3
  38. data/lib/otto/privacy/core.rb +104 -8
  39. data/lib/otto/privacy/geo_resolver.rb +228 -128
  40. data/lib/otto/privacy/ip_privacy.rb +24 -0
  41. data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
  42. data/lib/otto/privacy/user_agent_privacy.rb +64 -0
  43. data/lib/otto/privacy.rb +4 -1
  44. data/lib/otto/request.rb +35 -1
  45. data/lib/otto/route.rb +103 -41
  46. data/lib/otto/route_definition.rb +56 -6
  47. data/lib/otto/route_handlers/base.rb +4 -0
  48. data/lib/otto/route_handlers/factory.rb +15 -0
  49. data/lib/otto/route_handlers/lambda.rb +47 -32
  50. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  51. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  52. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  53. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  54. data/lib/otto/security/config.rb +123 -6
  55. data/lib/otto/security/core.rb +4 -1
  56. data/lib/otto/security/csp/policy.rb +135 -3
  57. data/lib/otto/security/csp/report_middleware.rb +3 -1
  58. data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
  59. data/lib/otto/security/csrf_validation.rb +75 -0
  60. data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
  61. data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
  62. data/lib/otto/security/rate_limiter.rb +7 -1
  63. data/lib/otto/security.rb +1 -0
  64. data/lib/otto/utils.rb +100 -0
  65. data/lib/otto/version.rb +1 -1
  66. data/lib/otto.rb +37 -5
  67. metadata +13 -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,32 +113,45 @@ 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
- # Removes specific version numbers (*.*.* pattern) and build identifiers
96
- # (e.g., Build/MRA58N) to reduce fingerprinting granularity while maintaining
97
- # browser/OS info.
147
+ # Delegates to the public {UserAgentPrivacy.anonymize} so there is a single
148
+ # source of truth for the reduction (removes version numbers and build
149
+ # identifiers, preserving browser/OS info) shared with downstream consumers.
98
150
  #
99
151
  # @param ua [String, nil] User agent string
100
152
  # @return [String, nil] Anonymized user agent or nil
101
153
  def anonymize_user_agent(ua)
102
- return nil if ua.nil? || ua.empty?
103
-
104
- # Remove build identifiers (e.g., Build/MRA58N, Build/MPJ24.139-64)
105
- # This must run BEFORE version stripping to avoid partial matches.
106
- # If we strip versions first, Build/MPJ24.139-64 becomes Build/MPJ*.*-64,
107
- # and the regex won't match properly (asterisks not in [\w.-] class).
108
- anonymized = ua.gsub(/Build\/[\w.-]+/, 'Build/*')
109
-
110
- # Remove version patterns (*.*.*.*, *.*.*, *.*)
111
- # Support both dot and underscore separators (e.g., 10.15.7 and 10_15_7)
112
- anonymized = anonymized
113
- .gsub(/\d+[._]\d+[._]\d+[._]\d+/, '*.*.*.*')
114
- .gsub(/\d+[._]\d+[._]\d+/, '*.*.*')
115
- .gsub(/\d+[._]\d+/, '*.*')
116
-
117
- # Truncate if too long (prevent DoS via huge UA strings)
118
- anonymized.length > 500 ? anonymized[0..499] : anonymized
154
+ UserAgentPrivacy.anonymize(ua)
119
155
  end
120
156
 
121
157
  # Anonymize referer URL
@@ -0,0 +1,64 @@
1
+ # lib/otto/privacy/user_agent_privacy.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ class Otto
6
+ module Privacy
7
+ # User-Agent anonymization utilities.
8
+ #
9
+ # Reduces a User-Agent string to a lower-entropy form by stripping build
10
+ # identifiers and version numbers and truncating, so it can be logged or
11
+ # stored for analytics without a high-entropy fingerprint while preserving
12
+ # browser/OS family information. This is the User-Agent analogue of
13
+ # {IPPrivacy} for IP addresses, exposed as a public surface so downstream
14
+ # consumers can reduce a UA outside of the full RedactedFingerprint /
15
+ # middleware flow without re-implementing (and drifting from) these regexes.
16
+ #
17
+ # @example
18
+ # UserAgentPrivacy.anonymize(
19
+ # 'Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36'
20
+ # )
21
+ # # => 'Mozilla/*.* (Windows NT *.*) Chrome/*.*.*.* Safari/*.*'
22
+ #
23
+ # @note Idempotent: re-anonymizing already-anonymized output is a no-op, so
24
+ # a UA reduced at the edge and one reduced again downstream agree.
25
+ class UserAgentPrivacy
26
+ # Default cap on the returned string, guarding against a DoS via a huge
27
+ # User-Agent header. Matches the length RedactedFingerprint has always
28
+ # applied.
29
+ DEFAULT_MAX_LENGTH = 500
30
+
31
+ # Anonymize a User-Agent string.
32
+ #
33
+ # Removes build identifiers (e.g. +Build/MRA58N+) and version numbers
34
+ # (+*.*.*.*+, +*.*.*+, +*.*+; dot- or underscore-separated), then
35
+ # truncates to +max_length+. Browser/OS family text is preserved -- the
36
+ # point is a partial, not a full redaction.
37
+ #
38
+ # Build identifiers are stripped BEFORE versions: if versions went first,
39
+ # a token like +Build/MPJ24.139-64+ would become +Build/MPJ*.*-64+ and the
40
+ # build regex (which matches only +[\w.-]+) would no longer catch it.
41
+ #
42
+ # @param ua [String, nil] the raw User-Agent string.
43
+ # @param max_length [Integer] maximum length of the returned string.
44
+ # @return [String, nil] the anonymized UA, or nil for nil/empty input.
45
+ def self.anonymize(ua, max_length: DEFAULT_MAX_LENGTH)
46
+ return nil if ua.nil? || ua.empty?
47
+
48
+ # Remove build identifiers (e.g., Build/MRA58N, Build/MPJ24.139-64).
49
+ # Must run BEFORE version stripping (see method note).
50
+ anonymized = ua.gsub(%r{Build/[\w.-]+}, 'Build/*')
51
+
52
+ # Remove version patterns (*.*.*.*, *.*.*, *.*), longest first.
53
+ # Support both dot and underscore separators (e.g. 10.15.7 and 10_15_7).
54
+ anonymized = anonymized
55
+ .gsub(/\d+[._]\d+[._]\d+[._]\d+/, '*.*.*.*')
56
+ .gsub(/\d+[._]\d+[._]\d+/, '*.*.*')
57
+ .gsub(/\d+[._]\d+/, '*.*')
58
+
59
+ # Truncate if too long (prevent DoS via huge UA strings).
60
+ anonymized.length > max_length ? anonymized[0...max_length] : anonymized
61
+ end
62
+ end
63
+ end
64
+ end
data/lib/otto/privacy.rb CHANGED
@@ -5,6 +5,7 @@
5
5
  require_relative 'privacy/core'
6
6
  require_relative 'privacy/config'
7
7
  require_relative 'privacy/ip_privacy'
8
+ require_relative 'privacy/user_agent_privacy'
8
9
  require_relative 'privacy/geo_resolver'
9
10
  require_relative 'privacy/redacted_fingerprint'
10
11
 
@@ -17,7 +18,9 @@ require_relative 'privacy/redacted_fingerprint'
17
18
  # Features:
18
19
  # - Configurable IP masking (1 or 2 octets for IPv4, 80 or 96 bits for IPv6)
19
20
  # - Daily-rotating IP hashing for session correlation without tracking
20
- # - 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
21
24
  # - User agent anonymization (removes version numbers)
22
25
  #
23
26
  # Privacy is ENABLED BY DEFAULT. To disable: