otto 2.0.0.pre1 → 2.0.0.pre3

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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +2 -3
  3. data/.github/workflows/claude-code-review.yml +30 -14
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.rubocop.yml +4 -1
  6. data/CHANGELOG.rst +54 -6
  7. data/CLAUDE.md +537 -0
  8. data/Gemfile +3 -2
  9. data/Gemfile.lock +34 -26
  10. data/benchmark_middleware_wrap.rb +163 -0
  11. data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
  12. data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
  13. data/docs/.gitignore +2 -0
  14. data/docs/ipaddr-encoding-quirk.md +34 -0
  15. data/docs/migrating/v2.0.0-pre2.md +338 -0
  16. data/examples/authentication_strategies/config.ru +0 -1
  17. data/lib/otto/core/configuration.rb +91 -41
  18. data/lib/otto/core/freezable.rb +93 -0
  19. data/lib/otto/core/middleware_stack.rb +103 -16
  20. data/lib/otto/core/router.rb +8 -7
  21. data/lib/otto/core.rb +8 -0
  22. data/lib/otto/env_keys.rb +118 -0
  23. data/lib/otto/helpers/base.rb +2 -21
  24. data/lib/otto/helpers/request.rb +80 -2
  25. data/lib/otto/helpers/response.rb +25 -3
  26. data/lib/otto/helpers.rb +4 -0
  27. data/lib/otto/locale/config.rb +56 -0
  28. data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
  29. data/lib/otto/mcp/server.rb +26 -13
  30. data/lib/otto/mcp.rb +3 -0
  31. data/lib/otto/privacy/config.rb +199 -0
  32. data/lib/otto/privacy/geo_resolver.rb +115 -0
  33. data/lib/otto/privacy/ip_privacy.rb +175 -0
  34. data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
  35. data/lib/otto/privacy.rb +29 -0
  36. data/lib/otto/response_handlers/json.rb +6 -0
  37. data/lib/otto/route.rb +44 -48
  38. data/lib/otto/route_handlers/base.rb +1 -2
  39. data/lib/otto/route_handlers/factory.rb +24 -9
  40. data/lib/otto/route_handlers/logic_class.rb +2 -2
  41. data/lib/otto/security/authentication/auth_failure.rb +44 -0
  42. data/lib/otto/security/authentication/auth_strategy.rb +3 -3
  43. data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
  44. data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
  45. data/lib/otto/security/authentication/strategy_result.rb +129 -15
  46. data/lib/otto/security/authentication.rb +5 -6
  47. data/lib/otto/security/config.rb +51 -18
  48. data/lib/otto/security/configurator.rb +2 -15
  49. data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
  50. data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
  51. data/lib/otto/security.rb +9 -0
  52. data/lib/otto/version.rb +1 -1
  53. data/lib/otto.rb +183 -89
  54. data/otto.gemspec +5 -0
  55. metadata +83 -8
  56. data/changelog.d/20250911_235619_delano_next.rst +0 -28
  57. data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
  58. data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
  59. data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
  60. data/lib/otto/security/authentication/failure_result.rb +0 -36
data/lib/otto/mcp.rb ADDED
@@ -0,0 +1,3 @@
1
+ # lib/otto/mcp.rb
2
+
3
+ require_relative 'mcp/server'
@@ -0,0 +1,199 @@
1
+ # lib/otto/privacy/config.rb
2
+
3
+ require 'ipaddr'
4
+ require 'securerandom'
5
+ require 'digest'
6
+
7
+ require 'concurrent'
8
+
9
+ require_relative '../core/freezable'
10
+
11
+ class Otto
12
+ module Privacy
13
+ # Configuration for IP privacy features
14
+ #
15
+ # Privacy is ENABLED by default for public IPs. Private/localhost IPs are not masked.
16
+ #
17
+ # @example Default configuration (privacy enabled)
18
+ # config = Otto::Privacy::Config.new
19
+ # config.enabled? # => true
20
+ #
21
+ # @example Configure masking level
22
+ # config = Otto::Privacy::Config.new
23
+ # config.octet_precision = 2 # Mask 2 octets instead of 1
24
+ #
25
+ class Config
26
+ include Otto::Core::Freezable
27
+
28
+ attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
29
+ attr_reader :disabled
30
+
31
+ # Class-level rotation key storage (mutable, not frozen with instances)
32
+ # This is stored at the class level so it persists across frozen config instances
33
+ @rotation_keys_store = nil
34
+
35
+ class << self
36
+ # Get the class-level rotation keys store
37
+ # @return [Concurrent::Map] Thread-safe map for rotation keys
38
+ def rotation_keys_store
39
+ @rotation_keys_store = Concurrent::Map.new unless defined?(@rotation_keys_store) && @rotation_keys_store
40
+ @rotation_keys_store
41
+ end
42
+ end
43
+
44
+ # Initialize privacy configuration
45
+ #
46
+ # @param options [Hash] Configuration options
47
+ # @option options [Integer] :octet_precision Number of trailing octets to mask (1 or 2, default: 1)
48
+ # @option options [Integer] :hash_rotation_period Seconds between key rotation (default: 86400)
49
+ # @option options [Boolean] :geo_enabled Enable geo-location resolution (default: true)
50
+ # @option options [Boolean] :disabled Disable privacy entirely (default: false)
51
+ # @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
52
+ # @option options [Redis] :redis Optional Redis connection for multi-server environments
53
+ def initialize(options = {})
54
+ @octet_precision = options.fetch(:octet_precision, 1)
55
+ @hash_rotation_period = options.fetch(:hash_rotation_period, 86_400) # 24 hours
56
+ @geo_enabled = options.fetch(:geo_enabled, true)
57
+ @disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
58
+ @mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
59
+ @redis = options[:redis] # Optional Redis connection for multi-server environments
60
+ end
61
+
62
+ # Check if privacy is enabled
63
+ #
64
+ # @return [Boolean] true if privacy is enabled (default)
65
+ def enabled?
66
+ !@disabled
67
+ end
68
+
69
+ # Check if privacy is disabled
70
+ #
71
+ # @return [Boolean] true if privacy was explicitly disabled
72
+ def disabled?
73
+ @disabled
74
+ end
75
+
76
+ # Disable privacy (allows access to original IPs)
77
+ #
78
+ # IMPORTANT: This should only be used when you have a specific
79
+ # requirement to access original IP addresses. By default, Otto
80
+ # provides privacy-safe masked IPs.
81
+ #
82
+ # @return [self]
83
+ def disable!
84
+ @disabled = true
85
+ self
86
+ end
87
+
88
+ # Enable privacy (default state)
89
+ #
90
+ # @return [self]
91
+ def enable!
92
+ @disabled = false
93
+ self
94
+ end
95
+
96
+ # Get the current rotation key for IP hashing
97
+ #
98
+ # Keys rotate at fixed intervals based on hash_rotation_period (default: 24 hours).
99
+ # Each rotation period gets a unique key, ensuring IP addresses hash differently
100
+ # across periods while remaining consistent within.
101
+ #
102
+ # Multi-server support:
103
+ # - With Redis: Uses SET NX GET EX for atomic key generation across all servers
104
+ # - Without Redis: Falls back to in-memory Concurrent::Hash (single-server only)
105
+ #
106
+ # Redis keys:
107
+ # - rotation_key:{timestamp} - Stores the rotation key with TTL
108
+ #
109
+ # @return [String] Current rotation key for hashing
110
+ def rotation_key
111
+ if @redis
112
+ rotation_key_redis
113
+ else
114
+ rotation_key_memory
115
+ end
116
+ end
117
+
118
+ # Validate configuration settings
119
+ #
120
+ # @raise [ArgumentError] if configuration is invalid
121
+ def validate!
122
+ raise ArgumentError, "octet_precision must be 1 or 2, got: #{@octet_precision}" unless [1,
123
+ 2].include?(@octet_precision)
124
+
125
+ return unless @hash_rotation_period < 60
126
+
127
+ raise ArgumentError, 'hash_rotation_period must be at least 60 seconds'
128
+ end
129
+
130
+ private
131
+
132
+ # Redis-based rotation key (atomic across multiple servers)
133
+ #
134
+ # Uses SET NX GET EX to atomically:
135
+ # 1. Check if key exists
136
+ # 2. Set new key only if missing
137
+ # 3. Return existing or newly set key
138
+ # 4. Auto-expire with TTL
139
+ #
140
+ # @return [String] Current rotation key
141
+ # @api private
142
+ def rotation_key_redis
143
+ now_seconds = Time.now.utc.to_i
144
+
145
+ # Quantize to rotation period boundary
146
+ rotation_timestamp = (now_seconds / @hash_rotation_period) * @hash_rotation_period
147
+
148
+ redis_key = "rotation_key:#{rotation_timestamp}"
149
+ ttl = (@hash_rotation_period * 1.2).to_i # Auto-cleanup with 20% buffer
150
+
151
+ key = SecureRandom.hex(32)
152
+
153
+ # SET NX GET returns old value if key exists, nil if we set it
154
+ # @see https://valkey.io/commands/set/
155
+ existing_key = @redis.set(redis_key, key, nx: true, get: true, ex: ttl)
156
+
157
+ existing_key || key
158
+ end
159
+
160
+ # In-memory rotation key (single-server fallback)
161
+ #
162
+ # Uses class-level Concurrent::Hash for thread-safety within a single process.
163
+ # NOT atomic across multiple servers.
164
+ #
165
+ # The rotation keys are stored at the class level so they remain mutable
166
+ # even when config instances are frozen.
167
+ #
168
+ # @return [String] Current rotation key
169
+ # @api private
170
+ def rotation_key_memory
171
+ rotation_keys = self.class.rotation_keys_store
172
+
173
+ now_seconds = Time.now.utc.to_i
174
+
175
+ # Quantize to rotation period boundary (e.g., midnight UTC for 24-hour period)
176
+ seconds_since_epoch = now_seconds % @hash_rotation_period
177
+ rotation_timestamp = now_seconds - seconds_since_epoch
178
+
179
+ # Atomically get or create key for this rotation period
180
+ # Use compute_if_absent for thread-safe atomic operation
181
+ key = rotation_keys.compute_if_absent(rotation_timestamp) do
182
+ # Generate new key atomically
183
+ # IMPORTANT: Don't modify the map inside this block to avoid deadlock
184
+ SecureRandom.hex(32)
185
+ end
186
+
187
+ # Clean up old keys after atomic operation completes
188
+ # This runs outside compute_if_absent to avoid deadlock
189
+ if rotation_keys.size > 1
190
+ rotation_keys.each_key do |ts|
191
+ rotation_keys.delete(ts) if ts != rotation_timestamp
192
+ end
193
+ end
194
+
195
+ key
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,115 @@
1
+ # lib/otto/privacy/geo_resolver.rb
2
+
3
+ require 'ipaddr'
4
+
5
+ class Otto
6
+ module Privacy
7
+ # Lightweight geo-location resolution for IP addresses
8
+ #
9
+ # Provides country-level geo-location without requiring external
10
+ # databases or API calls. Uses CloudFlare headers when available,
11
+ # with fallback to basic IP range detection.
12
+ #
13
+ # @example Resolve country from CloudFlare header
14
+ # env = { 'HTTP_CF_IPCOUNTRY' => 'US' }
15
+ # GeoResolver.resolve('1.2.3.4', env)
16
+ # # => 'US'
17
+ #
18
+ # @example Resolve without CloudFlare
19
+ # GeoResolver.resolve('9.9.9.9', {})
20
+ # # => 'CH' (Quad9 in Switzerland)
21
+ #
22
+ class GeoResolver
23
+ # Unknown country code (ISO 3166-1 alpha-2)
24
+ UNKNOWN = 'XX'
25
+
26
+ # Resolve country code for an IP address
27
+ #
28
+ # Resolution priority:
29
+ # 1. CloudFlare CF-IPCountry header (most reliable)
30
+ # 2. Basic IP range detection for major countries/providers
31
+ # 3. Return 'XX' for unknown
32
+ #
33
+ # @param ip [String] IP address to resolve
34
+ # @param env [Hash] Rack environment (may contain CF headers)
35
+ # @return [String] ISO 3166-1 alpha-2 country code or 'XX'
36
+ def self.resolve(ip, env = {})
37
+ return UNKNOWN if ip.nil? || ip.empty?
38
+
39
+ # Priority 1: CloudFlare header (free, accurate, no database)
40
+ cf_country = env['HTTP_CF_IPCOUNTRY']
41
+ return cf_country if cf_country && valid_country_code?(cf_country)
42
+
43
+ # Priority 2: Basic range detection
44
+ detect_by_range(ip)
45
+ rescue IPAddr::InvalidAddressError
46
+ UNKNOWN
47
+ end
48
+
49
+ # Detect country by IP range (basic implementation)
50
+ #
51
+ # Detects major cloud providers and well-known IP ranges.
52
+ # This is intentionally limited - for comprehensive geo-location,
53
+ # use CloudFlare or a dedicated GeoIP database.
54
+ #
55
+ # @param ip [String] IP address
56
+ # @return [String] Country code or 'XX'
57
+ # @api private
58
+ def self.detect_by_range(ip)
59
+ addr = IPAddr.new(ip)
60
+
61
+ # Private/local addresses
62
+ return UNKNOWN if IPPrivacy.private_or_localhost?(ip)
63
+
64
+ # Check against known ranges
65
+ KNOWN_RANGES.each do |range, country|
66
+ return country if range.include?(addr)
67
+ end
68
+
69
+ UNKNOWN
70
+ end
71
+ private_class_method :detect_by_range
72
+
73
+ # Validate country code format
74
+ #
75
+ # @param code [String] Country code to validate
76
+ # @return [Boolean] true if valid ISO 3166-1 alpha-2 code
77
+ # @api private
78
+ def self.valid_country_code?(code)
79
+ code.is_a?(String) && code.length == 2 && code.match?(/^[A-Z]{2}$/)
80
+ end
81
+ private_class_method :valid_country_code?
82
+
83
+ # Known IP ranges for major providers (limited set for basic detection)
84
+ # For comprehensive geo-location, use CloudFlare or GeoIP database
85
+ KNOWN_RANGES = {
86
+ # Google Public DNS
87
+ IPAddr.new('8.8.8.0/24') => 'US',
88
+ IPAddr.new('8.8.4.0/24') => 'US',
89
+
90
+ # Cloudflare DNS
91
+ IPAddr.new('1.1.1.0/24') => 'US',
92
+ IPAddr.new('1.0.0.0/24') => 'US',
93
+
94
+ # AWS US-East
95
+ IPAddr.new('52.0.0.0/11') => 'US',
96
+ IPAddr.new('54.0.0.0/8') => 'US',
97
+
98
+ # AWS EU-West
99
+ IPAddr.new('34.240.0.0/13') => 'IE',
100
+ IPAddr.new('52.16.0.0/14') => 'IE',
101
+
102
+ # AWS AP-Southeast
103
+ IPAddr.new('13.210.0.0/15') => 'AU',
104
+ IPAddr.new('52.62.0.0/15') => 'AU',
105
+
106
+ # Quad9 DNS (Switzerland)
107
+ IPAddr.new('9.9.9.0/24') => 'CH',
108
+
109
+ # OpenDNS
110
+ IPAddr.new('208.67.222.0/24') => 'US',
111
+ IPAddr.new('208.67.220.0/24') => 'US',
112
+ }.freeze
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,175 @@
1
+ # lib/otto/privacy/ip_privacy.rb
2
+
3
+ require 'ipaddr'
4
+ require 'digest'
5
+ require 'openssl'
6
+ require 'socket'
7
+
8
+ class Otto
9
+ module Privacy
10
+ # IP address anonymization utilities
11
+ #
12
+ # Provides methods for masking and hashing IP addresses to enhance
13
+ # privacy while maintaining the ability to track sessions and analyze
14
+ # traffic patterns.
15
+ #
16
+ # @example Mask an IPv4 address (1 octet)
17
+ # IPPrivacy.mask_ip('192.168.1.100', 1)
18
+ # # => '192.168.1.0'
19
+ #
20
+ # @example Mask an IPv4 address (2 octets)
21
+ # IPPrivacy.mask_ip('192.168.1.100', 2)
22
+ # # => '192.168.0.0'
23
+ #
24
+ # @example Hash an IP for session correlation
25
+ # key = 'daily-rotation-key'
26
+ # IPPrivacy.hash_ip('192.168.1.100', key)
27
+ # # => 'a3f8b2...' (consistent for same IP+key, changes when key rotates)
28
+ #
29
+ # @note All methods return UTF-8 encoded strings for Rack compatibility.
30
+ # See file:docs/ipaddr-encoding-quirk.md for details on IPAddr#to_s behavior.
31
+ #
32
+ class IPPrivacy
33
+ # Mask an IP address by zeroing out the specified number of octets/bits
34
+ #
35
+ # For IPv4:
36
+ # - octet_precision=1: Masks last octet (e.g., 192.168.1.100 → 192.168.1.0)
37
+ # - octet_precision=2: Masks last 2 octets (e.g., 192.168.1.100 → 192.168.0.0)
38
+ #
39
+ # For IPv6:
40
+ # - octet_precision=1: Masks last 80 bits
41
+ # - octet_precision=2: Masks last 96 bits
42
+ #
43
+ # @param ip [String] IP address to mask
44
+ # @param octet_precision [Integer] Number of trailing octets to mask (1 or 2, default: 1)
45
+ # @return [String] Masked IP address (UTF-8 encoded)
46
+ # @raise [ArgumentError] if IP is invalid or octet_precision is not 1 or 2
47
+ def self.mask_ip(ip, octet_precision = 1)
48
+ return nil if ip.nil? || ip.empty?
49
+
50
+ raise ArgumentError, "octet_precision must be 1 or 2, got: #{octet_precision}" unless [1,
51
+ 2].include?(octet_precision)
52
+
53
+ begin
54
+ addr = IPAddr.new(ip)
55
+
56
+ if addr.ipv4?
57
+ mask_ipv4(addr, octet_precision)
58
+ else
59
+ mask_ipv6(addr, octet_precision)
60
+ end
61
+ rescue IPAddr::InvalidAddressError => e
62
+ raise ArgumentError, "Invalid IP address: #{ip} - #{e.message}"
63
+ end
64
+ end
65
+
66
+ # Hash an IP address for session correlation without storing the original
67
+ #
68
+ # Uses HMAC-SHA256 with a daily-rotating key to create a consistent
69
+ # identifier for the same IP within a key rotation period, but different
70
+ # across rotations.
71
+ #
72
+ # @param ip [String] IP address to hash
73
+ # @param key [String] Secret key for HMAC (should rotate daily)
74
+ # @return [String] Hexadecimal hash string (64 characters)
75
+ # @raise [ArgumentError] if IP or key is invalid
76
+ def self.hash_ip(ip, key)
77
+ return nil if ip.nil? || ip.empty?
78
+
79
+ raise ArgumentError, 'Key cannot be nil or empty' if key.nil? || key.empty?
80
+
81
+ # Normalize IP address format before hashing
82
+ normalized_ip = begin
83
+ IPAddr.new(ip).to_s
84
+ rescue IPAddr::InvalidAddressError => e
85
+ raise ArgumentError, "Invalid IP address: #{ip} - #{e.message}"
86
+ end
87
+
88
+ # Use HMAC-SHA256 for secure hashing with key
89
+ OpenSSL::HMAC.hexdigest('SHA256', key, normalized_ip)
90
+ end
91
+
92
+ # Check if an IP address is valid
93
+ #
94
+ # @param ip [String] IP address to validate
95
+ # @return [Boolean] true if valid IPv4 or IPv6 address
96
+ def self.valid_ip?(ip)
97
+ return false if ip.nil? || ip.empty?
98
+
99
+ IPAddr.new(ip)
100
+ true
101
+ rescue IPAddr::InvalidAddressError
102
+ false
103
+ end
104
+
105
+ # Check if an IP address is localhost or private (RFC 1918)
106
+ #
107
+ # Private/localhost IPs are not masked for development convenience.
108
+ #
109
+ # @param ip [String] IP address to check
110
+ # @return [Boolean] true if IP is localhost or private
111
+ def self.private_or_localhost?(ip)
112
+ return false if ip.nil? || ip.empty?
113
+
114
+ addr = IPAddr.new(ip)
115
+ addr.private? || addr.loopback?
116
+ rescue IPAddr::InvalidAddressError
117
+ false
118
+ end
119
+
120
+ # Mask IPv4 address
121
+ #
122
+ # @param addr [IPAddr] IPAddr object (must be IPv4)
123
+ # @param octet_precision [Integer] Number of trailing octets to mask (1 or 2)
124
+ # @return [String] Masked IPv4 address (UTF-8 encoded)
125
+ # @api private
126
+ # @see file:docs/ipaddr-encoding-quirk.md IPAddr encoding behavior
127
+ def self.mask_ipv4(addr, octet_precision)
128
+ # Convert to integer for bitwise operations
129
+ ip_int = addr.to_i
130
+
131
+ # Create mask: 0xFFFFFFFF with trailing zeros
132
+ # octet_precision=1: 0xFFFFFF00 (mask last 8 bits)
133
+ # octet_precision=2: 0xFFFF0000 (mask last 16 bits)
134
+ bits_to_mask = octet_precision * 8
135
+ mask = (0xFFFFFFFF >> bits_to_mask) << bits_to_mask
136
+
137
+ # Apply mask and convert back to IP
138
+ masked_int = ip_int & mask
139
+
140
+ # Force UTF-8 encoding: IPAddr#to_s returns US-ASCII for IPv4 but UTF-8
141
+ # for IPv6. We normalize to UTF-8 for Rack compatibility and to prevent
142
+ # Encoding::CompatibilityError. Safe because IP strings contain only
143
+ # ASCII characters.
144
+ # See also: https://github.com/ruby/ruby/blob/master/lib/ipaddr.rb
145
+ IPAddr.new(masked_int, Socket::AF_INET).to_s.force_encoding('UTF-8')
146
+ end
147
+ private_class_method :mask_ipv4
148
+
149
+ # Mask IPv6 address
150
+ #
151
+ # @param addr [IPAddr] IPAddr object (must be IPv6)
152
+ # @param octet_precision [Integer] Number of trailing octets to mask (1 or 2)
153
+ # @return [String] Masked IPv6 address (UTF-8 encoded)
154
+ # @api private
155
+ def self.mask_ipv6(addr, octet_precision)
156
+ ip_int = addr.to_i
157
+
158
+ # octet_precision=1: Mask last 80 bits (leave first 48 bits for network)
159
+ # octet_precision=2: Mask last 96 bits (leave first 32 bits)
160
+ bits_to_mask = octet_precision == 1 ? 80 : 96
161
+
162
+ # Create mask by setting all 128 bits, then clearing the trailing bits we want to mask
163
+ # Example: For bits_to_mask=80, this creates a mask with first 48 bits set to 1, last 80 bits set to 0
164
+ # (1 << 128) - 1 creates 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF (all 128 bits set)
165
+ mask = ((1 << 128) - 1) >> bits_to_mask << bits_to_mask
166
+
167
+ masked_int = ip_int & mask
168
+
169
+ IPAddr.new(masked_int, Socket::AF_INET6).to_s.force_encoding('UTF-8')
170
+ end
171
+
172
+ private_class_method :mask_ipv6
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,136 @@
1
+ # lib/otto/privacy/redacted_fingerprint.rb
2
+
3
+ require 'securerandom'
4
+ require 'time'
5
+ require 'uri'
6
+
7
+ class Otto
8
+ module Privacy
9
+ # Immutable privacy-safe request fingerprint (aka CrappyFingerprint)
10
+ #
11
+ # Contains anonymized information about a request that can be used for
12
+ # logging, analytics, and session tracking without storing personally
13
+ # identifiable information.
14
+ #
15
+ # @example Create from Rack environment
16
+ # config = Otto::Privacy::Config.new
17
+ # fingerprint = RedactedFingerprint.new(env, config)
18
+ # fingerprint.masked_ip # => '192.168.1.0'
19
+ # fingerprint.country # => 'US'
20
+ #
21
+ class RedactedFingerprint
22
+ attr_reader :session_id, :timestamp, :masked_ip, :hashed_ip,
23
+ :country, :anonymized_ua, :request_path,
24
+ :request_method, :referer
25
+
26
+ # Create a new RedactedFingerprint from a Rack environment
27
+ #
28
+ # @param env [Hash] Rack environment hash
29
+ # @param config [Otto::Privacy::Config] Privacy configuration
30
+ def initialize(env, config)
31
+ remote_ip = env['REMOTE_ADDR']
32
+
33
+ @session_id = SecureRandom.uuid
34
+ @timestamp = Time.now.utc
35
+ @masked_ip = IPPrivacy.mask_ip(remote_ip, config.octet_precision)
36
+ @hashed_ip = IPPrivacy.hash_ip(remote_ip, config.rotation_key)
37
+ @country = config.geo_enabled ? GeoResolver.resolve(remote_ip, env) : nil
38
+ @anonymized_ua = anonymize_user_agent(env['HTTP_USER_AGENT'])
39
+ @request_path = env['PATH_INFO']
40
+ @request_method = env['REQUEST_METHOD']
41
+ @referer = anonymize_referer(env['HTTP_REFERER'])
42
+
43
+ freeze
44
+ end
45
+
46
+ # Convert to hash for logging or serialization
47
+ #
48
+ # @return [Hash] Hash representation of fingerprint
49
+ def to_h
50
+ {
51
+ session_id: @session_id,
52
+ timestamp: @timestamp.iso8601,
53
+ masked_ip: @masked_ip,
54
+ hashed_ip: @hashed_ip,
55
+ country: @country,
56
+ anonymized_ua: @anonymized_ua,
57
+ request_method: @request_method,
58
+ request_path: @request_path,
59
+ referer: @referer,
60
+ }
61
+ end
62
+
63
+ # Convert to JSON string
64
+ #
65
+ # @return [String] JSON representation
66
+ def to_json(*_args)
67
+ require 'json'
68
+ to_h.to_json
69
+ end
70
+
71
+ # String representation
72
+ #
73
+ # @return [String] Human-readable representation
74
+ def to_s
75
+ "#<RedactedFingerprint #{@hashed_ip[0..15]}... #{@country} #{@timestamp}>"
76
+ end
77
+
78
+ # Inspect representation
79
+ #
80
+ # @return [String] Detailed representation for debugging
81
+ def inspect
82
+ '#<Otto::Privacy::RedactedFingerprint ' \
83
+ "masked_ip=#{@masked_ip.inspect} " \
84
+ "hashed_ip=#{@hashed_ip[0..15]}... " \
85
+ "country=#{@country.inspect} " \
86
+ "timestamp=#{@timestamp.inspect}>"
87
+ end
88
+
89
+ private
90
+
91
+ # Anonymize user agent string by removing version numbers
92
+ #
93
+ # Removes specific version numbers (X.X.X pattern) to reduce
94
+ # fingerprinting granularity while maintaining browser/OS info.
95
+ #
96
+ # @param ua [String, nil] User agent string
97
+ # @return [String, nil] Anonymized user agent or nil
98
+ def anonymize_user_agent(ua)
99
+ return nil if ua.nil? || ua.empty?
100
+
101
+ # Remove version patterns (X.X.X.X, X.X.X, X.X)
102
+ anonymized = ua
103
+ .gsub(/\d+\.\d+\.\d+\.\d+/, 'X.X.X.X')
104
+ .gsub(/\d+\.\d+\.\d+/, 'X.X.X')
105
+ .gsub(/\d+\.\d+/, 'X.X')
106
+
107
+ # Truncate if too long (prevent DoS via huge UA strings)
108
+ anonymized.length > 500 ? anonymized[0..499] : anonymized
109
+ end
110
+
111
+ # Anonymize referer URL
112
+ #
113
+ # Strips query parameters and keeps only the path to reduce
114
+ # tracking potential while maintaining useful navigation data.
115
+ #
116
+ # @param referer [String, nil] Referer header value
117
+ # @return [String, nil] Anonymized referer or nil
118
+ def anonymize_referer(referer)
119
+ return nil if referer.nil? || referer.empty?
120
+
121
+ begin
122
+ uri = URI.parse(referer)
123
+ # Keep scheme, host, and path only (remove query and fragment)
124
+ if uri.scheme && uri.host
125
+ "#{uri.scheme}://#{uri.host}#{uri.path}"
126
+ else
127
+ uri.path
128
+ end
129
+ rescue URI::InvalidURIError
130
+ # If referer is malformed, return nil
131
+ nil
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'privacy/config'
4
+ require_relative 'privacy/ip_privacy'
5
+ require_relative 'privacy/geo_resolver'
6
+ require_relative 'privacy/redacted_fingerprint'
7
+
8
+ # Otto::Privacy module provides IP address anonymization and privacy features
9
+ #
10
+ # By default, Otto anonymizes IP addresses to enhance user privacy and
11
+ # comply with data protection regulations like GDPR. Original IP addresses
12
+ # are never stored unless privacy is explicitly disabled.
13
+ #
14
+ # Features:
15
+ # - Configurable IP masking (1 or 2 octets for IPv4, 80 or 96 bits for IPv6)
16
+ # - Daily-rotating IP hashing for session correlation without tracking
17
+ # - Geo-location resolution (country-level only, via CloudFlare headers)
18
+ # - User agent anonymization (removes version numbers)
19
+ #
20
+ # Privacy is ENABLED BY DEFAULT. To disable:
21
+ # otto.disable_ip_privacy!
22
+ #
23
+ # To configure privacy settings:
24
+ # otto.configure_ip_privacy(octet_precision: 2, geo: true)
25
+ #
26
+ class Otto
27
+ module Privacy
28
+ end
29
+ end
@@ -7,6 +7,12 @@ class Otto
7
7
  # Handler for JSON responses
8
8
  class JSONHandler < BaseHandler
9
9
  def self.handle(result, response, context = {})
10
+ # If a redirect has already been set, don't override with JSON
11
+ # This allows controllers to conditionally redirect based on Accept header
12
+ if response.status&.between?(300, 399) && response['Location']
13
+ return
14
+ end
15
+
10
16
  response['Content-Type'] = 'application/json'
11
17
 
12
18
  # Determine the data to serialize