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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -3
- data/.github/workflows/claude-code-review.yml +29 -13
- data/.github/workflows/code-smells.yml +146 -0
- data/.gitignore +4 -0
- data/.pre-commit-config.yaml +2 -2
- data/.reek.yml +99 -0
- data/CHANGELOG.rst +90 -0
- data/CLAUDE.md +116 -45
- data/Gemfile +5 -2
- data/Gemfile.lock +70 -24
- data/README.md +49 -1
- data/changelog.d/20251103_235431_delano_86_improve_error_logging.rst +15 -0
- data/changelog.d/20251109_025012_claude_fix_backtrace_sanitization.rst +37 -0
- data/docs/.gitignore +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/advanced_routes/README.md +137 -20
- data/examples/authentication_strategies/README.md +212 -19
- data/examples/authentication_strategies/config.ru +0 -1
- data/examples/backtrace_sanitization_demo.rb +86 -0
- data/examples/basic/README.md +61 -10
- data/examples/error_handler_registration.rb +136 -0
- data/examples/logging_improvements.rb +76 -0
- data/examples/mcp_demo/README.md +187 -27
- data/examples/security_features/README.md +249 -30
- data/examples/simple_geo_resolver.rb +107 -0
- data/lib/otto/core/configuration.rb +90 -45
- data/lib/otto/core/error_handler.rb +138 -8
- data/lib/otto/core/file_safety.rb +2 -2
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +25 -18
- data/lib/otto/core/router.rb +62 -9
- data/lib/otto/core/uri_generator.rb +2 -2
- data/lib/otto/core.rb +10 -0
- data/lib/otto/design_system.rb +2 -2
- data/lib/otto/env_keys.rb +65 -12
- data/lib/otto/helpers/base.rb +2 -2
- data/lib/otto/helpers/request.rb +85 -2
- data/lib/otto/helpers/response.rb +5 -5
- data/lib/otto/helpers/validation.rb +2 -2
- data/lib/otto/helpers.rb +6 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/locale/middleware.rb +160 -0
- data/lib/otto/locale.rb +10 -0
- data/lib/otto/logging_helpers.rb +273 -0
- data/lib/otto/mcp/auth/token.rb +2 -2
- data/lib/otto/mcp/protocol.rb +2 -2
- data/lib/otto/mcp/rate_limiting.rb +2 -2
- data/lib/otto/mcp/registry.rb +2 -2
- data/lib/otto/mcp/route_parser.rb +2 -2
- data/lib/otto/mcp/schema_validation.rb +2 -2
- data/lib/otto/mcp/server.rb +2 -2
- data/lib/otto/mcp.rb +5 -0
- data/lib/otto/privacy/config.rb +201 -0
- data/lib/otto/privacy/geo_resolver.rb +285 -0
- data/lib/otto/privacy/ip_privacy.rb +177 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +146 -0
- data/lib/otto/privacy.rb +31 -0
- data/lib/otto/response_handlers/auto.rb +2 -0
- data/lib/otto/response_handlers/base.rb +2 -0
- data/lib/otto/response_handlers/default.rb +2 -0
- data/lib/otto/response_handlers/factory.rb +2 -0
- data/lib/otto/response_handlers/json.rb +2 -0
- data/lib/otto/response_handlers/redirect.rb +2 -0
- data/lib/otto/response_handlers/view.rb +2 -0
- data/lib/otto/response_handlers.rb +2 -2
- data/lib/otto/route.rb +4 -4
- data/lib/otto/route_definition.rb +42 -15
- data/lib/otto/route_handlers/base.rb +2 -1
- data/lib/otto/route_handlers/class_method.rb +18 -25
- data/lib/otto/route_handlers/factory.rb +18 -16
- data/lib/otto/route_handlers/instance_method.rb +8 -5
- data/lib/otto/route_handlers/lambda.rb +8 -20
- data/lib/otto/route_handlers/logic_class.rb +25 -8
- data/lib/otto/route_handlers.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +5 -5
- data/lib/otto/security/authentication/auth_strategy.rb +13 -6
- data/lib/otto/security/authentication/route_auth_wrapper.rb +304 -41
- data/lib/otto/security/authentication/strategies/api_key_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +7 -1
- data/lib/otto/security/authentication/strategies/permission_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/role_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategies/session_strategy.rb +2 -0
- data/lib/otto/security/authentication/strategy_result.rb +6 -5
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/authorization_error.rb +73 -0
- data/lib/otto/security/config.rb +53 -9
- data/lib/otto/security/configurator.rb +17 -15
- data/lib/otto/security/csrf.rb +2 -2
- data/lib/otto/security/middleware/csrf_middleware.rb +11 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +231 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +2 -0
- data/lib/otto/security/middleware/validation_middleware.rb +15 -0
- data/lib/otto/security/rate_limiter.rb +2 -2
- data/lib/otto/security/rate_limiting.rb +2 -2
- data/lib/otto/security/validator.rb +2 -2
- data/lib/otto/security.rb +12 -0
- data/lib/otto/static.rb +2 -2
- data/lib/otto/utils.rb +27 -2
- data/lib/otto/version.rb +3 -3
- data/lib/otto.rb +344 -89
- data/otto.gemspec +9 -2
- metadata +72 -8
- 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
|
data/lib/otto/privacy.rb
ADDED
|
@@ -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
|