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
@@ -0,0 +1,285 @@
1
+ # lib/otto/privacy/geo_resolver.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'ipaddr'
6
+
7
+ class Otto
8
+ module Privacy
9
+ # Lightweight geo-location resolution for IP addresses
10
+ #
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.
15
+ #
16
+ # Supported CDN/Infrastructure Headers:
17
+ # - Cloudflare: CF-IPCountry
18
+ # - AWS CloudFront: CloudFront-Viewer-Country
19
+ # - Fastly: Fastly-Client-IP-Country
20
+ # - Akamai: X-Akamai-Edgescape (country_code=XX format)
21
+ # - Azure Front Door: X-Azure-ClientIP-Country
22
+ # - Semi-standard: X-Geo-Country, X-Country-Code, Country-Code
23
+ #
24
+ # @example Resolve country from Cloudflare header
25
+ # env = { 'HTTP_CF_IPCOUNTRY' => 'US' }
26
+ # GeoResolver.resolve('1.2.3.4', env)
27
+ # # => 'US'
28
+ #
29
+ # @example Resolve from AWS CloudFront
30
+ # env = { 'HTTP_CLOUDFRONT_VIEWER_COUNTRY' => 'GB' }
31
+ # GeoResolver.resolve('1.2.3.4', env)
32
+ # # => 'GB'
33
+ #
34
+ # @example Resolve without CDN headers
35
+ # GeoResolver.resolve('8.8.8.8', {})
36
+ # # => 'US' (Google DNS via range detection)
37
+ #
38
+ # @example Using a custom resolver (MaxMind)
39
+ # GeoResolver.custom_resolver = ->(ip, env) {
40
+ # reader = MaxMind::DB.new('GeoLite2-Country.mmdb')
41
+ # result = reader.get(ip)
42
+ # result&.dig('country', 'iso_code')
43
+ # }
44
+ # GeoResolver.resolve('1.2.3.4', {}) # Uses custom resolver
45
+ #
46
+ # @example Extending via subclass
47
+ # class MyGeoResolver < Otto::Privacy::GeoResolver
48
+ # def self.detect_by_range(ip)
49
+ # # Custom logic here
50
+ # super # Fall back to parent
51
+ # end
52
+ # end
53
+ #
54
+ #
55
+ # Resolution flow
56
+ #
57
+ # Request → Has familiar HTTP Header?
58
+ # ├─ Yes → Return country (Cloudflare, AWS, etc.)
59
+ # └─ No → Custom Resolver?
60
+ # ├─ Configured → Call & validate
61
+ # │ ├─ Valid → Return country
62
+ # │ └─ Invalid/Error → Continue
63
+ # └─ Not configured → Built-in range detection
64
+ # └─ Unknown ('**')
65
+ #
66
+ class GeoResolver
67
+ # Unknown country code (not ISO 3166-1 alpha-2, intentionally distinct)
68
+ UNKNOWN = '**'
69
+
70
+ # Custom resolver for extending geo-location capabilities
71
+ # Can be set to a proc/lambda or a class responding to .call(ip, env)
72
+ #
73
+ # @example Using a proc
74
+ # GeoResolver.custom_resolver = ->(ip, env) {
75
+ # return nil # Return nil to continue with built-in resolution
76
+ # # or return 'US' to provide custom country code
77
+ # }
78
+ #
79
+ # @example Using MaxMind
80
+ # GeoResolver.custom_resolver = ->(ip, env) {
81
+ # reader = MaxMind::DB.new('path/to/GeoLite2-Country.mmdb')
82
+ # result = reader.get(ip)
83
+ # result&.dig('country', 'iso_code')
84
+ # }
85
+ #
86
+ # Thread Safety Model:
87
+ # This follows Ruby's standard configuration pattern:
88
+ # - Set ONCE at boot time (single-threaded initialization)
89
+ # - Read many times during requests (multi-threaded reads)
90
+ # - No synchronization needed for this access pattern
91
+ #
92
+ # Runtime resolver switching is NOT supported. Changing the resolver
93
+ # while processing requests creates race conditions (write vs read).
94
+ # This is intentional - resolver configuration is boot-time only.
95
+ @custom_resolver = nil
96
+
97
+ class << self
98
+ attr_accessor :custom_resolver
99
+
100
+ # Set a custom resolver for geo-location
101
+ #
102
+ # MUST be called during single-threaded initialization (before
103
+ # accepting requests). Runtime changes while serving requests
104
+ # will cause race conditions.
105
+ #
106
+ # @param resolver [Proc, #call] A proc or callable object that takes (ip, env)
107
+ # and returns a country code string or nil
108
+ # @raise [ArgumentError] if resolver doesn't respond to :call
109
+ def custom_resolver=(resolver)
110
+ unless resolver.nil? || resolver.respond_to?(:call)
111
+ raise ArgumentError, 'Custom resolver must respond to :call'
112
+ end
113
+ @custom_resolver = resolver
114
+ end
115
+ end
116
+
117
+ # Resolve country code for an IP address
118
+ #
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
123
+ #
124
+ # @param ip [String] IP address to resolve
125
+ # @param env [Hash] Rack environment (may contain geo headers)
126
+ # @return [String] ISO 3166-1 alpha-2 country code or '**'
127
+ def self.resolve(ip, env = {})
128
+ return UNKNOWN if ip.nil? || ip.empty?
129
+
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
134
+
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
144
+ end
145
+
146
+ # Fallback: Basic range detection
147
+ detect_by_range(ip)
148
+ rescue IPAddr::InvalidAddressError
149
+ UNKNOWN
150
+ end
151
+
152
+ # Check CDN/infrastructure provider geo headers
153
+ #
154
+ # Headers are checked in order of reliability and deployment frequency:
155
+ # 1. Cloudflare (CF-IPCountry) - Most widely deployed
156
+ # 2. AWS CloudFront (CloudFront-Viewer-Country)
157
+ # 3. Fastly (Fastly-Client-IP-Country)
158
+ # 4. Akamai (X-Akamai-Edgescape) - Complex format, extract country
159
+ # 5. Azure Front Door (X-Azure-ClientIP-Country)
160
+ # 6. Semi-standard headers (X-Geo-Country, X-Country-Code, Country-Code)
161
+ #
162
+ # @param env [Hash] Rack environment
163
+ # @return [String, nil] ISO 3166-1 alpha-2 country code or nil
164
+ # @api private
165
+ 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)
177
+
178
+ # Akamai Edgescape (format: country_code=US,region_code=CA,...)
179
+ if (edgescape = env['HTTP_X_AKAMAI_EDGESCAPE'])
180
+ country = extract_akamai_country(edgescape)
181
+ return country if valid_country_code?(country)
182
+ end
183
+
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)
197
+
198
+ nil
199
+ end
200
+ private_class_method :check_geo_headers
201
+
202
+ # Extract country code from Akamai Edgescape header
203
+ #
204
+ # Edgescape format: "country_code=US,region_code=CA,city=LOSANGELES,..."
205
+ #
206
+ # @param edgescape [String] Akamai Edgescape header value
207
+ # @return [String, nil] Country code or nil
208
+ # @api private
209
+ def self.extract_akamai_country(edgescape)
210
+ return nil unless edgescape.is_a?(String)
211
+
212
+ # Extract country_code=XX (must be exactly 2 uppercase letters, bounded)
213
+ # Use word boundary or comma to ensure we don't match partial strings like "INVALID"
214
+ match = edgescape.match(/country_code=([A-Z]{2})(?:,|\z)/)
215
+ match ? match[1] : nil
216
+ end
217
+ private_class_method :extract_akamai_country
218
+
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
+ # Validate country code format
275
+ #
276
+ # @param code [String] Country code to validate
277
+ # @return [Boolean] true if valid ISO 3166-1 alpha-2 code
278
+ # @api private
279
+ def self.valid_country_code?(code)
280
+ code.is_a?(String) && code.length == 2 && code.match?(/^[A-Z]{2}$/)
281
+ end
282
+ private_class_method :valid_country_code?
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,177 @@
1
+ # lib/otto/privacy/ip_privacy.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'ipaddr'
6
+ require 'digest'
7
+ require 'openssl'
8
+ require 'socket'
9
+
10
+ class Otto
11
+ module Privacy
12
+ # IP address anonymization utilities
13
+ #
14
+ # Provides methods for masking and hashing IP addresses to enhance
15
+ # privacy while maintaining the ability to track sessions and analyze
16
+ # traffic patterns.
17
+ #
18
+ # @example Mask an IPv4 address (1 octet)
19
+ # IPPrivacy.mask_ip('192.168.1.100', 1)
20
+ # # => '192.168.1.0'
21
+ #
22
+ # @example Mask an IPv4 address (2 octets)
23
+ # IPPrivacy.mask_ip('192.168.1.100', 2)
24
+ # # => '192.168.0.0'
25
+ #
26
+ # @example Hash an IP for session correlation
27
+ # key = 'daily-rotation-key'
28
+ # IPPrivacy.hash_ip('192.168.1.100', key)
29
+ # # => 'a3f8b2...' (consistent for same IP+key, changes when key rotates)
30
+ #
31
+ # @note All methods return UTF-8 encoded strings for Rack compatibility.
32
+ # See file:docs/ipaddr-encoding-quirk.md for details on IPAddr#to_s behavior.
33
+ #
34
+ class IPPrivacy
35
+ # Mask an IP address by zeroing out the specified number of octets/bits
36
+ #
37
+ # For IPv4:
38
+ # - octet_precision=1: Masks last octet (e.g., 192.168.1.100 → 192.168.1.0)
39
+ # - octet_precision=2: Masks last 2 octets (e.g., 192.168.1.100 → 192.168.0.0)
40
+ #
41
+ # For IPv6:
42
+ # - octet_precision=1: Masks last 80 bits
43
+ # - octet_precision=2: Masks last 96 bits
44
+ #
45
+ # @param ip [String] IP address to mask
46
+ # @param octet_precision [Integer] Number of trailing octets to mask (1 or 2, default: 1)
47
+ # @return [String] Masked IP address (UTF-8 encoded)
48
+ # @raise [ArgumentError] if IP is invalid or octet_precision is not 1 or 2
49
+ def self.mask_ip(ip, octet_precision = 1)
50
+ return nil if ip.nil? || ip.empty?
51
+
52
+ raise ArgumentError, "octet_precision must be 1 or 2, got: #{octet_precision}" unless [1,
53
+ 2].include?(octet_precision)
54
+
55
+ begin
56
+ addr = IPAddr.new(ip)
57
+
58
+ if addr.ipv4?
59
+ mask_ipv4(addr, octet_precision)
60
+ else
61
+ mask_ipv6(addr, octet_precision)
62
+ end
63
+ rescue IPAddr::InvalidAddressError => e
64
+ raise ArgumentError, "Invalid IP address: #{ip} - #{e.message}"
65
+ end
66
+ end
67
+
68
+ # Hash an IP address for session correlation without storing the original
69
+ #
70
+ # Uses HMAC-SHA256 with a daily-rotating key to create a consistent
71
+ # identifier for the same IP within a key rotation period, but different
72
+ # across rotations.
73
+ #
74
+ # @param ip [String] IP address to hash
75
+ # @param key [String] Secret key for HMAC (should rotate daily)
76
+ # @return [String] Hexadecimal hash string (64 characters)
77
+ # @raise [ArgumentError] if IP or key is invalid
78
+ def self.hash_ip(ip, key)
79
+ return nil if ip.nil? || ip.empty?
80
+
81
+ raise ArgumentError, 'Key cannot be nil or empty' if key.nil? || key.empty?
82
+
83
+ # Normalize IP address format before hashing
84
+ normalized_ip = begin
85
+ IPAddr.new(ip).to_s
86
+ rescue IPAddr::InvalidAddressError => e
87
+ raise ArgumentError, "Invalid IP address: #{ip} - #{e.message}"
88
+ end
89
+
90
+ # Use HMAC-SHA256 for secure hashing with key
91
+ OpenSSL::HMAC.hexdigest('SHA256', key, normalized_ip)
92
+ end
93
+
94
+ # Check if an IP address is valid
95
+ #
96
+ # @param ip [String] IP address to validate
97
+ # @return [Boolean] true if valid IPv4 or IPv6 address
98
+ def self.valid_ip?(ip)
99
+ return false if ip.nil? || ip.empty?
100
+
101
+ IPAddr.new(ip)
102
+ true
103
+ rescue IPAddr::InvalidAddressError
104
+ false
105
+ end
106
+
107
+ # Check if an IP address is localhost or private (RFC 1918)
108
+ #
109
+ # Private/localhost IPs are not masked for development convenience.
110
+ #
111
+ # @param ip [String] IP address to check
112
+ # @return [Boolean] true if IP is localhost or private
113
+ def self.private_or_localhost?(ip)
114
+ return false if ip.nil? || ip.empty?
115
+
116
+ addr = IPAddr.new(ip)
117
+ addr.private? || addr.loopback?
118
+ rescue IPAddr::InvalidAddressError
119
+ false
120
+ end
121
+
122
+ # Mask IPv4 address
123
+ #
124
+ # @param addr [IPAddr] IPAddr object (must be IPv4)
125
+ # @param octet_precision [Integer] Number of trailing octets to mask (1 or 2)
126
+ # @return [String] Masked IPv4 address (UTF-8 encoded)
127
+ # @api private
128
+ # @see file:docs/ipaddr-encoding-quirk.md IPAddr encoding behavior
129
+ def self.mask_ipv4(addr, octet_precision)
130
+ # Convert to integer for bitwise operations
131
+ ip_int = addr.to_i
132
+
133
+ # Create mask: 0xFFFFFFFF with trailing zeros
134
+ # octet_precision=1: 0xFFFFFF00 (mask last 8 bits)
135
+ # octet_precision=2: 0xFFFF0000 (mask last 16 bits)
136
+ bits_to_mask = octet_precision * 8
137
+ mask = (0xFFFFFFFF >> bits_to_mask) << bits_to_mask
138
+
139
+ # Apply mask and convert back to IP
140
+ masked_int = ip_int & mask
141
+
142
+ # Force UTF-8 encoding: IPAddr#to_s returns US-ASCII for IPv4 but UTF-8
143
+ # for IPv6. We normalize to UTF-8 for Rack compatibility and to prevent
144
+ # Encoding::CompatibilityError. Safe because IP strings contain only
145
+ # ASCII characters.
146
+ # See also: https://github.com/ruby/ruby/blob/master/lib/ipaddr.rb
147
+ IPAddr.new(masked_int, Socket::AF_INET).to_s.force_encoding('UTF-8')
148
+ end
149
+ private_class_method :mask_ipv4
150
+
151
+ # Mask IPv6 address
152
+ #
153
+ # @param addr [IPAddr] IPAddr object (must be IPv6)
154
+ # @param octet_precision [Integer] Number of trailing octets to mask (1 or 2)
155
+ # @return [String] Masked IPv6 address (UTF-8 encoded)
156
+ # @api private
157
+ def self.mask_ipv6(addr, octet_precision)
158
+ ip_int = addr.to_i
159
+
160
+ # octet_precision=1: Mask last 80 bits (leave first 48 bits for network)
161
+ # octet_precision=2: Mask last 96 bits (leave first 32 bits)
162
+ bits_to_mask = octet_precision == 1 ? 80 : 96
163
+
164
+ # Create mask by setting all 128 bits, then clearing the trailing bits we want to mask
165
+ # Example: For bits_to_mask=80, this creates a mask with first 48 bits set to 1, last 80 bits set to 0
166
+ # (1 << 128) - 1 creates 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF (all 128 bits set)
167
+ mask = ((1 << 128) - 1) >> bits_to_mask << bits_to_mask
168
+
169
+ masked_int = ip_int & mask
170
+
171
+ IPAddr.new(masked_int, Socket::AF_INET6).to_s.force_encoding('UTF-8')
172
+ end
173
+
174
+ private_class_method :mask_ipv6
175
+ end
176
+ end
177
+ end
@@ -0,0 +1,146 @@
1
+ # lib/otto/privacy/redacted_fingerprint.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require 'securerandom'
6
+ require 'time'
7
+ require 'uri'
8
+
9
+ class Otto
10
+ module Privacy
11
+ # Immutable privacy-safe request fingerprint (aka CrappyFingerprint)
12
+ #
13
+ # Contains anonymized information about a request that can be used for
14
+ # logging, analytics, and session tracking without storing personally
15
+ # identifiable information.
16
+ #
17
+ # @example Create from Rack environment
18
+ # config = Otto::Privacy::Config.new
19
+ # fingerprint = RedactedFingerprint.new(env, config)
20
+ # fingerprint.masked_ip # => '192.168.1.0'
21
+ # fingerprint.country # => 'US'
22
+ #
23
+ class RedactedFingerprint
24
+ attr_reader :session_id, :timestamp, :masked_ip, :hashed_ip,
25
+ :country, :anonymized_ua, :request_path,
26
+ :request_method, :referer
27
+
28
+ # Create a new RedactedFingerprint from a Rack environment
29
+ #
30
+ # @param env [Hash] Rack environment hash
31
+ # @param config [Otto::Privacy::Config] Privacy configuration
32
+ def initialize(env, config)
33
+ remote_ip = env['REMOTE_ADDR']
34
+
35
+ @session_id = SecureRandom.uuid
36
+ @timestamp = Time.now.utc
37
+ @masked_ip = IPPrivacy.mask_ip(remote_ip, config.octet_precision)
38
+ @hashed_ip = IPPrivacy.hash_ip(remote_ip, config.rotation_key)
39
+ @country = config.geo_enabled ? GeoResolver.resolve(remote_ip, env) : nil
40
+ @anonymized_ua = anonymize_user_agent(env['HTTP_USER_AGENT'])
41
+ @request_path = env['PATH_INFO']
42
+ @request_method = env['REQUEST_METHOD']
43
+ @referer = anonymize_referer(env['HTTP_REFERER'])
44
+
45
+ freeze
46
+ end
47
+
48
+ # Convert to hash for logging or serialization
49
+ #
50
+ # @return [Hash] Hash representation of fingerprint
51
+ def to_h
52
+ {
53
+ session_id: @session_id,
54
+ timestamp: @timestamp.iso8601,
55
+ masked_ip: @masked_ip,
56
+ hashed_ip: @hashed_ip,
57
+ country: @country,
58
+ anonymized_ua: @anonymized_ua,
59
+ request_method: @request_method,
60
+ request_path: @request_path,
61
+ referer: @referer,
62
+ }
63
+ end
64
+
65
+ # Convert to JSON string
66
+ #
67
+ # @return [String] JSON representation
68
+ def to_json(*_args)
69
+ require 'json'
70
+ to_h.to_json
71
+ end
72
+
73
+ # String representation
74
+ #
75
+ # @return [String] Human-readable representation
76
+ def to_s
77
+ "#<RedactedFingerprint #{@hashed_ip[0..15]}... #{@country} #{@timestamp}>"
78
+ end
79
+
80
+ # Inspect representation
81
+ #
82
+ # @return [String] Detailed representation for debugging
83
+ def inspect
84
+ '#<Otto::Privacy::RedactedFingerprint ' \
85
+ "masked_ip=#{@masked_ip.inspect} " \
86
+ "hashed_ip=#{@hashed_ip[0..15]}... " \
87
+ "country=#{@country.inspect} " \
88
+ "timestamp=#{@timestamp.inspect}>"
89
+ end
90
+
91
+ private
92
+
93
+ # Anonymize user agent string by removing version numbers and build identifiers
94
+ #
95
+ # Removes specific version numbers (*.*.* pattern) and build identifiers
96
+ # (e.g., Build/MRA58N) to reduce fingerprinting granularity while maintaining
97
+ # browser/OS info.
98
+ #
99
+ # @param ua [String, nil] User agent string
100
+ # @return [String, nil] Anonymized user agent or nil
101
+ 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
119
+ end
120
+
121
+ # Anonymize referer URL
122
+ #
123
+ # Strips query parameters and keeps only the path to reduce
124
+ # tracking potential while maintaining useful navigation data.
125
+ #
126
+ # @param referer [String, nil] Referer header value
127
+ # @return [String, nil] Anonymized referer or nil
128
+ def anonymize_referer(referer)
129
+ return nil if referer.nil? || referer.empty?
130
+
131
+ begin
132
+ uri = URI.parse(referer)
133
+ # Keep scheme, host, and path only (remove query and fragment)
134
+ if uri.scheme && uri.host
135
+ "#{uri.scheme}://#{uri.host}#{uri.path}"
136
+ else
137
+ uri.path
138
+ end
139
+ rescue URI::InvalidURIError
140
+ # If referer is malformed, return nil
141
+ nil
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,31 @@
1
+ # lib/otto/privacy.rb
2
+ #
3
+ # frozen_string_literal: true
4
+
5
+ require_relative 'privacy/config'
6
+ require_relative 'privacy/ip_privacy'
7
+ require_relative 'privacy/geo_resolver'
8
+ require_relative 'privacy/redacted_fingerprint'
9
+
10
+ # Otto::Privacy module provides IP address anonymization and privacy features
11
+ #
12
+ # By default, Otto anonymizes IP addresses to enhance user privacy and
13
+ # comply with data protection regulations like GDPR. Original IP addresses
14
+ # are never stored unless privacy is explicitly disabled.
15
+ #
16
+ # Features:
17
+ # - Configurable IP masking (1 or 2 octets for IPv4, 80 or 96 bits for IPv6)
18
+ # - Daily-rotating IP hashing for session correlation without tracking
19
+ # - Geo-location resolution (country-level only, via CloudFlare headers)
20
+ # - User agent anonymization (removes version numbers)
21
+ #
22
+ # Privacy is ENABLED BY DEFAULT. To disable:
23
+ # otto.disable_ip_privacy!
24
+ #
25
+ # To configure privacy settings:
26
+ # otto.configure_ip_privacy(octet_precision: 2, geo: true)
27
+ #
28
+ class Otto
29
+ module Privacy
30
+ end
31
+ end
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/auto.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/base.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  class Otto
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/default.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/factory.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'json'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/json.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/redirect.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'
@@ -1,3 +1,5 @@
1
+ # lib/otto/response_handlers/view.rb
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
5
  require_relative 'base'