otto 2.0.0.pre2 → 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 +0 -2
- data/.github/workflows/claude-code-review.yml +29 -13
- data/CLAUDE.md +537 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +17 -10
- 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 +1 -0
- data/docs/ipaddr-encoding-quirk.md +34 -0
- data/docs/migrating/v2.0.0-pre2.md +11 -18
- data/examples/authentication_strategies/config.ru +0 -1
- data/lib/otto/core/configuration.rb +89 -39
- data/lib/otto/core/freezable.rb +93 -0
- data/lib/otto/core/middleware_stack.rb +24 -17
- data/lib/otto/core/router.rb +1 -1
- data/lib/otto/core.rb +8 -0
- data/lib/otto/env_keys.rb +8 -4
- data/lib/otto/helpers/request.rb +80 -2
- data/lib/otto/helpers/response.rb +3 -3
- data/lib/otto/helpers.rb +4 -0
- data/lib/otto/locale/config.rb +56 -0
- 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/route_handlers/base.rb +1 -2
- data/lib/otto/route_handlers/factory.rb +16 -14
- data/lib/otto/route_handlers/logic_class.rb +2 -2
- data/lib/otto/security/authentication/{failure_result.rb → auth_failure.rb} +3 -3
- data/lib/otto/security/authentication/auth_strategy.rb +3 -3
- data/lib/otto/security/authentication/route_auth_wrapper.rb +137 -26
- data/lib/otto/security/authentication/strategies/noauth_strategy.rb +5 -1
- data/lib/otto/security/authentication.rb +3 -4
- data/lib/otto/security/config.rb +51 -7
- data/lib/otto/security/configurator.rb +0 -13
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +211 -0
- data/lib/otto/security.rb +9 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +181 -86
- data/otto.gemspec +3 -0
- metadata +58 -3
- data/lib/otto/security/authentication/authentication_middleware.rb +0 -140
|
@@ -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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# lib/otto/route_handlers/factory.rb
|
|
4
4
|
|
|
5
5
|
require_relative 'base'
|
|
6
|
+
require_relative '../security/authentication/route_auth_wrapper'
|
|
6
7
|
|
|
7
8
|
class Otto
|
|
8
9
|
module RouteHandlers
|
|
@@ -14,24 +15,25 @@ class Otto
|
|
|
14
15
|
# @return [BaseHandler] Appropriate handler for the route
|
|
15
16
|
def self.create_handler(route_definition, otto_instance = nil)
|
|
16
17
|
# Create base handler based on route kind
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
else
|
|
25
|
-
raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
|
|
26
|
-
end
|
|
18
|
+
handler_class = case route_definition.kind
|
|
19
|
+
when :logic then LogicClassHandler
|
|
20
|
+
when :instance then InstanceMethodHandler
|
|
21
|
+
when :class then ClassMethodHandler
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "Unknown handler kind: #{route_definition.kind}"
|
|
24
|
+
end
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
handler = handler_class.new(route_definition, otto_instance)
|
|
27
|
+
|
|
28
|
+
# Always wrap with RouteAuthWrapper to ensure env['otto.strategy_result'] is set
|
|
29
|
+
# - Routes WITH auth requirement: Enforces authentication
|
|
30
|
+
# - Routes WITHOUT auth requirement: Sets anonymous StrategyResult
|
|
31
|
+
if otto_instance&.auth_config
|
|
31
32
|
handler = Otto::Security::Authentication::RouteAuthWrapper.new(
|
|
32
33
|
handler,
|
|
33
34
|
route_definition,
|
|
34
|
-
otto_instance.auth_config
|
|
35
|
+
otto_instance.auth_config,
|
|
36
|
+
otto_instance.security_config
|
|
35
37
|
)
|
|
36
38
|
end
|
|
37
39
|
|
|
@@ -17,8 +17,8 @@ class Otto
|
|
|
17
17
|
res = Rack::Response.new
|
|
18
18
|
|
|
19
19
|
begin
|
|
20
|
-
# Get strategy result (guaranteed to exist from
|
|
21
|
-
strategy_result = env['otto.strategy_result']
|
|
20
|
+
# Get strategy result (guaranteed to exist from RouteAuthWrapper)
|
|
21
|
+
strategy_result = env['otto.strategy_result']
|
|
22
22
|
|
|
23
23
|
# Initialize Logic class with new signature: context, params, locale
|
|
24
24
|
logic_params = req.params.merge(extra_params)
|
|
@@ -6,8 +6,8 @@ class Otto
|
|
|
6
6
|
module Security
|
|
7
7
|
module Authentication
|
|
8
8
|
# Failure result for authentication failures
|
|
9
|
-
|
|
10
|
-
#
|
|
9
|
+
AuthFailure = Data.define(:failure_reason, :auth_method) do
|
|
10
|
+
# AuthFailure represents authentication failure
|
|
11
11
|
# Returned by strategies when authentication fails
|
|
12
12
|
# Contains failure reason for error messages
|
|
13
13
|
|
|
@@ -36,7 +36,7 @@ class Otto
|
|
|
36
36
|
#
|
|
37
37
|
# @return [String] Debug representation
|
|
38
38
|
def inspect
|
|
39
|
-
"#<
|
|
39
|
+
"#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
end
|
|
@@ -13,7 +13,7 @@ class Otto
|
|
|
13
13
|
# Check if the request meets the authentication requirements
|
|
14
14
|
# @param env [Hash] Rack environment
|
|
15
15
|
# @param requirement [String] Authentication requirement string
|
|
16
|
-
# @return [Otto::Security::Authentication::StrategyResult,
|
|
16
|
+
# @return [Otto::Security::Authentication::StrategyResult, Otto::Security::Authentication::AuthFailure] StrategyResult for success, AuthFailure for failure
|
|
17
17
|
def authenticate(env, requirement)
|
|
18
18
|
raise NotImplementedError, 'Subclasses must implement #authenticate'
|
|
19
19
|
end
|
|
@@ -30,10 +30,10 @@ class Otto
|
|
|
30
30
|
)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
# Helper for authentication failure - return
|
|
33
|
+
# Helper for authentication failure - return AuthFailure
|
|
34
34
|
def failure(reason = nil)
|
|
35
35
|
Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
|
|
36
|
-
Otto::Security::Authentication::
|
|
36
|
+
Otto::Security::Authentication::AuthFailure.new(
|
|
37
37
|
failure_reason: reason || 'Authentication failed',
|
|
38
38
|
auth_method: self.class.name.split('::').last
|
|
39
39
|
)
|