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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -3
- data/.github/workflows/claude-code-review.yml +30 -14
- data/.github/workflows/claude.yml +1 -1
- data/.rubocop.yml +4 -1
- data/CHANGELOG.rst +54 -6
- data/CLAUDE.md +537 -0
- data/Gemfile +3 -2
- data/Gemfile.lock +34 -26
- data/benchmark_middleware_wrap.rb +163 -0
- data/changelog.d/20251014_144317_delano_54_thats_a_wrapper.rst +36 -0
- data/changelog.d/20251014_161526_delano_54_thats_a_wrapper.rst +5 -0
- data/docs/.gitignore +2 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +338 -0
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +91 -41
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +103 -16
- data/lib/otto/core/router.rb +8 -7
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +118 -0
- data/lib/otto/helpers/base.rb +2 -21
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +25 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- data/lib/otto/mcp/{validation.rb → schema_validation.rb} +3 -2
- data/lib/otto/mcp/server.rb +26 -13
- data/lib/otto/mcp.rb +3 -0
- data/lib/otto/privacy/config.rb +199 -0
- data/lib/otto/privacy/geo_resolver.rb +115 -0
- data/lib/otto/privacy/ip_privacy.rb +175 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +136 -0
- data/lib/otto/privacy.rb +29 -0
- data/lib/otto/response_handlers/json.rb +6 -0
- data/lib/otto/route.rb +44 -48
- data/lib/otto/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +24 -9
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/auth_failure.rb +44 -0
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +260 -0
- data/lib/otto/security/authentication/strategies/{public_strategy.rb → noauth_strategy.rb} +6 -2
- data/lib/otto/security/authentication/strategy_result.rb +129 -15
- data/lib/otto/security/authentication.rb +5 -6
- data/lib/otto/security/config.rb +51 -18
- data/lib/otto/security/configurator.rb +2 -15
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security/middleware/rate_limit_middleware.rb +19 -3
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +183 -89
- data/otto.gemspec +5 -0
- metadata +83 -8
- data/changelog.d/20250911_235619_delano_next.rst +0 -28
- data/changelog.d/20250912_123055_delano_remove_ostruct.rst +0 -21
- data/changelog.d/20250912_175625_claude_delano_remove_ostruct.rst +0 -21
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -123
- data/lib/otto/security/authentication/failure_result.rb +0 -36
data/lib/otto/mcp.rb
ADDED
|
@@ -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
|
data/lib/otto/privacy.rb
ADDED
|
@@ -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
|