otto 2.5.0 → 2.7.0
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 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +283 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +14 -12
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +172 -0
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +43 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +281 -3
- data/lib/otto/privacy/core.rb +104 -8
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +4 -1
- data/lib/otto/request.rb +35 -1
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +123 -6
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/policy.rb +135 -3
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +37 -5
- metadata +13 -6
|
@@ -2,16 +2,33 @@
|
|
|
2
2
|
#
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
require 'ipaddr'
|
|
6
|
-
|
|
7
5
|
class Otto
|
|
8
6
|
module Privacy
|
|
9
7
|
# Lightweight geo-location resolution for IP addresses
|
|
10
8
|
#
|
|
11
|
-
# Provides country-level geo-location
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
9
|
+
# Provides country-level geo-location. Headers from major CDN/infrastructure
|
|
10
|
+
# providers are checked first; an optional local MaxMind-format (.mmdb)
|
|
11
|
+
# database gives an offline fallback that operates on Otto's already-MASKED
|
|
12
|
+
# IP (no external API calls, and the unmasked address never reaches the
|
|
13
|
+
# resolver).
|
|
14
|
+
#
|
|
15
|
+
# Resolution order (first hit wins), when a privacy Config is supplied:
|
|
16
|
+
# 1. App-configured trusted header (Config#geo_header), e.g. 'X-Client-Country'
|
|
17
|
+
# 2. Built-in CDN/infrastructure provider headers (see below)
|
|
18
|
+
# 3. Custom resolver hook ({.custom_resolver})
|
|
19
|
+
# 4. Local MMDB lookup, masked before lookup (Config#geo_db_reader)
|
|
20
|
+
# 5. '**' (unknown)
|
|
21
|
+
#
|
|
22
|
+
# Steps 1 and 2 are SKIPPED when the request's geo headers are not trusted.
|
|
23
|
+
# Every geo header is client-spoofable unless you are actually behind the CDN
|
|
24
|
+
# that sets it, so the middleware trusts them only for a request that arrived
|
|
25
|
+
# via a configured trusted proxy; otherwise resolution falls straight to the
|
|
26
|
+
# custom resolver / database.
|
|
27
|
+
#
|
|
28
|
+
# Resolution is HONEST: when no header, custom resolver, or database resolves
|
|
29
|
+
# a country, the answer is '**' (unknown). Otto does not guess from a
|
|
30
|
+
# hardcoded IP-range table — configure a database or an edge header for real
|
|
31
|
+
# geo-location.
|
|
15
32
|
#
|
|
16
33
|
# Supported CDN/Infrastructure Headers:
|
|
17
34
|
# - Cloudflare: CF-IPCountry
|
|
@@ -19,6 +36,7 @@ class Otto
|
|
|
19
36
|
# - Fastly: Fastly-Client-IP-Country
|
|
20
37
|
# - Akamai: X-Akamai-Edgescape (country_code=XX format)
|
|
21
38
|
# - Azure Front Door: X-Azure-ClientIP-Country
|
|
39
|
+
# - Vercel: X-Vercel-IP-Country
|
|
22
40
|
# - Semi-standard: X-Geo-Country, X-Country-Code, Country-Code
|
|
23
41
|
#
|
|
24
42
|
# @example Resolve country from Cloudflare header
|
|
@@ -31,9 +49,9 @@ class Otto
|
|
|
31
49
|
# GeoResolver.resolve('1.2.3.4', env)
|
|
32
50
|
# # => 'GB'
|
|
33
51
|
#
|
|
34
|
-
# @example Resolve without
|
|
52
|
+
# @example Resolve without any header, resolver, or database
|
|
35
53
|
# GeoResolver.resolve('8.8.8.8', {})
|
|
36
|
-
# # => '
|
|
54
|
+
# # => '**' (unknown — Otto does not guess)
|
|
37
55
|
#
|
|
38
56
|
# @example Using a custom resolver (MaxMind)
|
|
39
57
|
# GeoResolver.custom_resolver = ->(ip, env) {
|
|
@@ -45,7 +63,7 @@ class Otto
|
|
|
45
63
|
#
|
|
46
64
|
# @example Extending via subclass
|
|
47
65
|
# class MyGeoResolver < Otto::Privacy::GeoResolver
|
|
48
|
-
# def self.
|
|
66
|
+
# def self.check_geo_database(ip, config)
|
|
49
67
|
# # Custom logic here
|
|
50
68
|
# super # Fall back to parent
|
|
51
69
|
# end
|
|
@@ -54,14 +72,16 @@ class Otto
|
|
|
54
72
|
#
|
|
55
73
|
# Resolution flow
|
|
56
74
|
#
|
|
57
|
-
# Request →
|
|
58
|
-
# ├─ Yes →
|
|
59
|
-
# └─
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
75
|
+
# Request → Headers trusted?
|
|
76
|
+
# ├─ Yes → Config#geo_header set & valid? → Return country
|
|
77
|
+
# │ └─ Provider header present & valid? → Return country
|
|
78
|
+
# └─ (headers skipped when not trusted)
|
|
79
|
+
# → Custom Resolver configured?
|
|
80
|
+
# ├─ Valid → Return country
|
|
81
|
+
# └─ Invalid/Error → Continue
|
|
82
|
+
# → Local MMDB reader configured?
|
|
83
|
+
# ├─ Hit → Return country
|
|
84
|
+
# └─ Miss → Unknown ('**')
|
|
65
85
|
#
|
|
66
86
|
class GeoResolver
|
|
67
87
|
# Unknown country code (not ISO 3166-1 alpha-2, intentionally distinct)
|
|
@@ -114,40 +134,150 @@ class Otto
|
|
|
114
134
|
end
|
|
115
135
|
end
|
|
116
136
|
|
|
117
|
-
# Resolve country code for an IP address
|
|
137
|
+
# Resolve country code for an IP address.
|
|
138
|
+
#
|
|
139
|
+
# Resolution order (first hit wins). Header steps (1–2) are skipped when
|
|
140
|
+
# +headers_trusted+ is false, since geo headers are client-spoofable
|
|
141
|
+
# unless the request actually arrived through the trusted CDN/proxy:
|
|
142
|
+
# 1. App-configured trusted header (+config.geo_header+)
|
|
143
|
+
# 2. Built-in CDN/infrastructure provider headers
|
|
144
|
+
# 3. Custom resolver hook ({.custom_resolver})
|
|
145
|
+
# 4. Local MMDB lookup (+config.geo_db_reader+), masked before lookup
|
|
146
|
+
# 5. '**' for unknown (no guessing)
|
|
118
147
|
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
148
|
+
# Country-level MMDB networks are almost always >= /24, so a /24-masked
|
|
149
|
+
# +x.y.z.0+ resolves to the same country as the real IP. The database
|
|
150
|
+
# lookup masks internally, and the Otto middleware additionally hands this
|
|
151
|
+
# method a masked IP and a masked env, so neither the database nor a custom
|
|
152
|
+
# resolver ever sees the unmasked address.
|
|
123
153
|
#
|
|
124
|
-
# @param ip [String] IP address to resolve
|
|
125
|
-
#
|
|
154
|
+
# @param ip [String] IP address to resolve. When called from the Otto
|
|
155
|
+
# middleware this is already the masked IP; the database lookup masks
|
|
156
|
+
# again internally, so a direct caller passing a real IP still never
|
|
157
|
+
# exposes the unmasked address to the database.
|
|
158
|
+
# @param env [Hash] Rack environment (may contain geo headers). In the
|
|
159
|
+
# framework path this is a masked view (REMOTE_ADDR/forwarded headers
|
|
160
|
+
# masked), so a custom resolver never sees the raw IP through env either.
|
|
161
|
+
# @param config [Otto::Privacy::Config, nil] privacy config supplying the
|
|
162
|
+
# configured header and MMDB reader. When nil, only the built-in provider
|
|
163
|
+
# headers and the custom resolver are consulted.
|
|
164
|
+
# @param headers_trusted [Boolean] whether request geo headers may be
|
|
165
|
+
# trusted for this request (default true; the middleware computes this
|
|
166
|
+
# from the trusted-proxy decision).
|
|
126
167
|
# @return [String] ISO 3166-1 alpha-2 country code or '**'
|
|
127
|
-
def self.resolve(ip, env = {})
|
|
168
|
+
def self.resolve(ip, env = {}, config = nil, headers_trusted: true)
|
|
128
169
|
return UNKNOWN if ip.nil? || ip.empty?
|
|
129
170
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
171
|
+
# Resolution is honest: when no header, custom resolver, or database
|
|
172
|
+
# resolves a country, the answer is '**' (unknown) — never a guess.
|
|
173
|
+
resolve_from_sources(ip, env, config, headers_trusted) || UNKNOWN
|
|
174
|
+
end
|
|
134
175
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
176
|
+
# Walk the ordered resolution sources and return the first country found.
|
|
177
|
+
#
|
|
178
|
+
# @return [String, nil] country code, or nil if no source resolved
|
|
179
|
+
# @api private
|
|
180
|
+
def self.resolve_from_sources(ip, env, config, headers_trusted)
|
|
181
|
+
if headers_trusted
|
|
182
|
+
# 1. Configured header wins over 2. built-in provider headers.
|
|
183
|
+
country = check_configured_header(env, config) || check_geo_headers(env)
|
|
184
|
+
return country if country
|
|
144
185
|
end
|
|
145
186
|
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
187
|
+
# 3. Custom resolver hook, then 4. local MMDB lookup.
|
|
188
|
+
check_custom_resolver(ip, env) || check_geo_database(ip, config)
|
|
189
|
+
end
|
|
190
|
+
private_class_method :resolve_from_sources
|
|
191
|
+
|
|
192
|
+
# Check the app-configured trusted geo header, if any.
|
|
193
|
+
#
|
|
194
|
+
# @param env [Hash] Rack environment
|
|
195
|
+
# @param config [Otto::Privacy::Config, nil]
|
|
196
|
+
# @return [String, nil] valid country code from the configured header, or nil
|
|
197
|
+
# @api private
|
|
198
|
+
def self.check_configured_header(env, config)
|
|
199
|
+
header = config&.geo_header
|
|
200
|
+
return nil unless header
|
|
201
|
+
|
|
202
|
+
country = env[header]
|
|
203
|
+
valid_country_code?(country) ? country : nil
|
|
204
|
+
end
|
|
205
|
+
private_class_method :check_configured_header
|
|
206
|
+
|
|
207
|
+
# Invoke the configured custom resolver, guarding against errors.
|
|
208
|
+
#
|
|
209
|
+
# @param ip [String] IP address handed to the resolver (masked in the
|
|
210
|
+
# framework path — see {.resolve})
|
|
211
|
+
# @param env [Hash] Rack environment (masked view in the framework path)
|
|
212
|
+
# @return [String, nil] a valid country code, or nil
|
|
213
|
+
# @api private
|
|
214
|
+
def self.check_custom_resolver(ip, env)
|
|
215
|
+
resolver = @custom_resolver
|
|
216
|
+
return nil unless resolver
|
|
217
|
+
|
|
218
|
+
country = resolver.call(ip, env)
|
|
219
|
+
country if country && valid_country_code?(country)
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
# A custom resolver must never crash a request; fall through.
|
|
222
|
+
warn "GeoResolver custom resolver error: #{e.message}" if $DEBUG
|
|
223
|
+
nil
|
|
150
224
|
end
|
|
225
|
+
private_class_method :check_custom_resolver
|
|
226
|
+
|
|
227
|
+
# Look up the country for an IP in the configured MMDB database.
|
|
228
|
+
#
|
|
229
|
+
# No-op (returns nil) when no config or reader is available. The IP is
|
|
230
|
+
# masked (with the config's octet precision) before the lookup so the
|
|
231
|
+
# unmasked address never reaches the database; masking is idempotent, so
|
|
232
|
+
# an already-masked IP is unaffected. Country-level networks are >= /24,
|
|
233
|
+
# so a /24-masked address resolves to the same country as the real one.
|
|
234
|
+
#
|
|
235
|
+
# The reader is any object responding to +#get(ip)+; result shapes from
|
|
236
|
+
# both GeoLite2-Country-compatible ('country' => {'iso_code' => ...}) and
|
|
237
|
+
# flat ('country_code' => ...) mmdb builds are handled.
|
|
238
|
+
#
|
|
239
|
+
# @param ip [String] IP address (masked internally before lookup)
|
|
240
|
+
# @param config [Otto::Privacy::Config, nil]
|
|
241
|
+
# @return [String, nil] valid country code, or nil on miss/error
|
|
242
|
+
# @api private
|
|
243
|
+
def self.check_geo_database(ip, config)
|
|
244
|
+
reader = config&.geo_db_reader
|
|
245
|
+
return nil unless reader
|
|
246
|
+
|
|
247
|
+
lookup_ip = IPPrivacy.mask_ip(ip, config.octet_precision) || ip
|
|
248
|
+
country = extract_db_country(reader.get(lookup_ip))
|
|
249
|
+
valid_country_code?(country) ? country : nil
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
# A DB read must never crash a request; fall through.
|
|
252
|
+
warn "GeoResolver database lookup error: #{e.message}" if $DEBUG
|
|
253
|
+
nil
|
|
254
|
+
end
|
|
255
|
+
private_class_method :check_geo_database
|
|
256
|
+
|
|
257
|
+
# Extract an ISO country code from an MMDB lookup result.
|
|
258
|
+
#
|
|
259
|
+
# Handles the shapes country databases actually use: GeoLite2-Country
|
|
260
|
+
# style ('country' => {'iso_code' => 'US'}), the flat 'country_code' =>
|
|
261
|
+
# 'US', and a bare 'country' => 'US'. The nested case is checked with an
|
|
262
|
+
# explicit Hash guard rather than Hash#dig so a bare-String 'country'
|
|
263
|
+
# value does not raise (String has no #dig).
|
|
264
|
+
#
|
|
265
|
+
# @param result [Object] whatever the reader returned for the IP
|
|
266
|
+
# @return [String, nil] country code string, or nil
|
|
267
|
+
# @api private
|
|
268
|
+
def self.extract_db_country(result)
|
|
269
|
+
return nil unless result.is_a?(Hash)
|
|
270
|
+
|
|
271
|
+
country = result['country']
|
|
272
|
+
code =
|
|
273
|
+
if country.is_a?(Hash)
|
|
274
|
+
country['iso_code']
|
|
275
|
+
else
|
|
276
|
+
result['country_code'] || country
|
|
277
|
+
end
|
|
278
|
+
code.is_a?(String) ? code : nil
|
|
279
|
+
end
|
|
280
|
+
private_class_method :extract_db_country
|
|
151
281
|
|
|
152
282
|
# Check CDN/infrastructure provider geo headers
|
|
153
283
|
#
|
|
@@ -157,47 +287,70 @@ class Otto
|
|
|
157
287
|
# 3. Fastly (Fastly-Client-IP-Country)
|
|
158
288
|
# 4. Akamai (X-Akamai-Edgescape) - Complex format, extract country
|
|
159
289
|
# 5. Azure Front Door (X-Azure-ClientIP-Country)
|
|
160
|
-
# 6.
|
|
290
|
+
# 6. Vercel (X-Vercel-IP-Country)
|
|
291
|
+
# 7. Semi-standard headers (X-Geo-Country, X-Country-Code, Country-Code)
|
|
292
|
+
#
|
|
293
|
+
# Simple provider headers whose value is the country code directly, checked
|
|
294
|
+
# ahead of Akamai (whose value is a compound Edgescape string). Cloudflare
|
|
295
|
+
# first (most widely deployed), then AWS CloudFront, then Fastly.
|
|
296
|
+
PRIMARY_COUNTRY_HEADERS = %w[
|
|
297
|
+
HTTP_CF_IPCOUNTRY
|
|
298
|
+
HTTP_CLOUDFRONT_VIEWER_COUNTRY
|
|
299
|
+
HTTP_FASTLY_CLIENT_IP_COUNTRY
|
|
300
|
+
].freeze
|
|
301
|
+
|
|
302
|
+
# Remaining direct country-code headers, checked after Akamai: Azure Front
|
|
303
|
+
# Door, Vercel, then the least-reliable semi-standard headers.
|
|
304
|
+
SECONDARY_COUNTRY_HEADERS = %w[
|
|
305
|
+
HTTP_X_AZURE_CLIENTIP_COUNTRY
|
|
306
|
+
HTTP_X_VERCEL_IP_COUNTRY
|
|
307
|
+
HTTP_X_GEO_COUNTRY
|
|
308
|
+
HTTP_X_COUNTRY_CODE
|
|
309
|
+
HTTP_COUNTRY_CODE
|
|
310
|
+
].freeze
|
|
311
|
+
|
|
312
|
+
# Check CDN/infrastructure provider geo headers, in priority order:
|
|
313
|
+
# Cloudflare, AWS CloudFront, Fastly, Akamai, Azure, Vercel, then the
|
|
314
|
+
# semi-standard headers.
|
|
161
315
|
#
|
|
162
316
|
# @param env [Hash] Rack environment
|
|
163
317
|
# @return [String, nil] ISO 3166-1 alpha-2 country code or nil
|
|
164
318
|
# @api private
|
|
165
319
|
def self.check_geo_headers(env)
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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)
|
|
320
|
+
first_valid_country(env, PRIMARY_COUNTRY_HEADERS) ||
|
|
321
|
+
akamai_country(env) ||
|
|
322
|
+
first_valid_country(env, SECONDARY_COUNTRY_HEADERS)
|
|
323
|
+
end
|
|
324
|
+
private_class_method :check_geo_headers
|
|
177
325
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
326
|
+
# First valid country code among the given env header keys, or nil.
|
|
327
|
+
#
|
|
328
|
+
# @param env [Hash] Rack environment
|
|
329
|
+
# @param keys [Array<String>] env keys to check in order
|
|
330
|
+
# @return [String, nil]
|
|
331
|
+
# @api private
|
|
332
|
+
def self.first_valid_country(env, keys)
|
|
333
|
+
keys.each do |key|
|
|
334
|
+
country = env[key]
|
|
181
335
|
return country if valid_country_code?(country)
|
|
182
336
|
end
|
|
337
|
+
nil
|
|
338
|
+
end
|
|
339
|
+
private_class_method :first_valid_country
|
|
183
340
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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)
|
|
341
|
+
# Country code from the Akamai Edgescape header, if present and valid.
|
|
342
|
+
#
|
|
343
|
+
# @param env [Hash] Rack environment
|
|
344
|
+
# @return [String, nil]
|
|
345
|
+
# @api private
|
|
346
|
+
def self.akamai_country(env)
|
|
347
|
+
edgescape = env['HTTP_X_AKAMAI_EDGESCAPE']
|
|
348
|
+
return nil unless edgescape
|
|
197
349
|
|
|
198
|
-
|
|
350
|
+
country = extract_akamai_country(edgescape)
|
|
351
|
+
valid_country_code?(country) ? country : nil
|
|
199
352
|
end
|
|
200
|
-
private_class_method :
|
|
353
|
+
private_class_method :akamai_country
|
|
201
354
|
|
|
202
355
|
# Extract country code from Akamai Edgescape header
|
|
203
356
|
#
|
|
@@ -216,68 +369,15 @@ class Otto
|
|
|
216
369
|
end
|
|
217
370
|
private_class_method :extract_akamai_country
|
|
218
371
|
|
|
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
372
|
# Validate country code format
|
|
275
373
|
#
|
|
276
374
|
# @param code [String] Country code to validate
|
|
277
375
|
# @return [Boolean] true if valid ISO 3166-1 alpha-2 code
|
|
278
376
|
# @api private
|
|
279
377
|
def self.valid_country_code?(code)
|
|
280
|
-
|
|
378
|
+
# \A / \z (string anchors), not ^ / $ (line anchors), so an embedded
|
|
379
|
+
# newline can never satisfy the pattern.
|
|
380
|
+
code.is_a?(String) && code.length == 2 && code.match?(/\A[A-Z]{2}\z/)
|
|
281
381
|
end
|
|
282
382
|
private_class_method :valid_country_code?
|
|
283
383
|
end
|
|
@@ -119,6 +119,30 @@ class Otto
|
|
|
119
119
|
false
|
|
120
120
|
end
|
|
121
121
|
|
|
122
|
+
# Redact the client identifier(s) in an RFC 7239 Forwarded header value.
|
|
123
|
+
#
|
|
124
|
+
# Only the `for=` node value is replaced with the masked IP; `proto=`,
|
|
125
|
+
# `host=`, `by=` and the overall structure are preserved so downstream
|
|
126
|
+
# scheme/host/proxy decisions (absolute URLs, redirects, secure cookies)
|
|
127
|
+
# still work. Every `for=` element in the chain is redacted. IPv6 is
|
|
128
|
+
# bracketed and quoted as the RFC requires. This is the structured-header
|
|
129
|
+
# counterpart to the wholesale X-Forwarded-For swap.
|
|
130
|
+
#
|
|
131
|
+
# @param value [String, nil] the Forwarded header value
|
|
132
|
+
# @param masked_ip [String, nil] the masked client IP
|
|
133
|
+
# @return [String, nil] the header with every `for=` value redacted, or
|
|
134
|
+
# the value unchanged when there is nothing to mask
|
|
135
|
+
def self.mask_forwarded_for(value, masked_ip)
|
|
136
|
+
return value if value.nil? || masked_ip.nil? || masked_ip.empty?
|
|
137
|
+
|
|
138
|
+
replacement = masked_ip.include?(':') ? %("[#{masked_ip}]") : masked_ip
|
|
139
|
+
# Match `for=` only at an element/pair boundary (start, comma, or
|
|
140
|
+
# semicolon) so a parameter merely ending in "for" is never touched.
|
|
141
|
+
value.gsub(/(\A|[,;]\s*)for\s*=\s*("[^"]*"|[^;,]+)/i) do
|
|
142
|
+
"#{Regexp.last_match(1)}for=#{replacement}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
122
146
|
# Mask IPv4 address
|
|
123
147
|
#
|
|
124
148
|
# @param addr [IPAddr] IPAddr object (must be IPv4)
|
|
@@ -25,18 +25,41 @@ class Otto
|
|
|
25
25
|
:country, :anonymized_ua, :request_path,
|
|
26
26
|
:request_method, :referer
|
|
27
27
|
|
|
28
|
+
# IP-bearing forwarded headers overwritten with the masked IP in the
|
|
29
|
+
# geo-resolution env view. Mirrors the set
|
|
30
|
+
# IPPrivacyMiddleware#mask_forwarded_headers rewrites, so a custom resolver
|
|
31
|
+
# reading env sees masked values everywhere the middleware would. The
|
|
32
|
+
# structured RFC 7239 Forwarded header (HTTP_FORWARDED) is handled
|
|
33
|
+
# separately in {#geo_env} (dropped, not swapped, to keep valid syntax).
|
|
34
|
+
GEO_MASKED_FORWARDED_HEADERS = %w[
|
|
35
|
+
HTTP_X_FORWARDED_FOR
|
|
36
|
+
HTTP_X_REAL_IP
|
|
37
|
+
HTTP_X_CLIENT_IP
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
28
40
|
# Create a new RedactedFingerprint from a Rack environment
|
|
29
41
|
#
|
|
30
42
|
# @param env [Hash] Rack environment hash
|
|
31
43
|
# @param config [Otto::Privacy::Config] Privacy configuration
|
|
32
|
-
|
|
44
|
+
# @param geo_headers_trusted [Boolean] whether request geo headers may be
|
|
45
|
+
# trusted for this request. The middleware passes false for a
|
|
46
|
+
# non-trusted-proxy request when trusted proxies are configured, so
|
|
47
|
+
# spoofed geo headers are ignored. Defaults to true for standalone use.
|
|
48
|
+
def initialize(env, config, geo_headers_trusted: true)
|
|
33
49
|
remote_ip = env['REMOTE_ADDR']
|
|
34
50
|
|
|
35
51
|
@session_id = SecureRandom.uuid
|
|
36
52
|
@timestamp = Time.now.utc
|
|
37
53
|
@masked_ip = IPPrivacy.mask_ip(remote_ip, config.octet_precision)
|
|
38
54
|
@hashed_ip = IPPrivacy.hash_ip(remote_ip, config.rotation_key)
|
|
39
|
-
|
|
55
|
+
# hashed_ip is computed above from the real IP; geo resolution then runs
|
|
56
|
+
# against a MASKED view — the masked IP AND an env with the IP-bearing
|
|
57
|
+
# headers masked — so neither a custom resolver nor the database can see
|
|
58
|
+
# the unmasked address, via the argument or via env. Country-level
|
|
59
|
+
# networks are >= /24, so the /24-masked IP resolves to the same country.
|
|
60
|
+
@country = if config.geo_enabled
|
|
61
|
+
GeoResolver.resolve(@masked_ip, geo_env(env), config, headers_trusted: geo_headers_trusted)
|
|
62
|
+
end
|
|
40
63
|
@anonymized_ua = anonymize_user_agent(env['HTTP_USER_AGENT'])
|
|
41
64
|
@request_path = env['PATH_INFO']
|
|
42
65
|
@request_method = env['REQUEST_METHOD']
|
|
@@ -90,32 +113,45 @@ class Otto
|
|
|
90
113
|
|
|
91
114
|
private
|
|
92
115
|
|
|
116
|
+
# A shallow copy of env with the client-IP fields masked, for geo
|
|
117
|
+
# resolution. Country-level geo needs nothing finer than the masked /24,
|
|
118
|
+
# so a custom resolver (arbitrary app code that might log or forward what
|
|
119
|
+
# it receives) is handed only the masked address here — never the raw host
|
|
120
|
+
# IP that env['REMOTE_ADDR'] and the forwarded headers still carry at this
|
|
121
|
+
# point in the middleware. Non-IP keys (including geo headers like
|
|
122
|
+
# CF-IPCountry) are preserved. Returns env unchanged when there is no
|
|
123
|
+
# masked IP (nothing to hide, and geo resolution short-circuits anyway).
|
|
124
|
+
#
|
|
125
|
+
# @param env [Hash] Rack environment
|
|
126
|
+
# @return [Hash] masked env view
|
|
127
|
+
def geo_env(env)
|
|
128
|
+
return env if @masked_ip.nil?
|
|
129
|
+
|
|
130
|
+
masked = env.dup
|
|
131
|
+
masked['REMOTE_ADDR'] = @masked_ip
|
|
132
|
+
GEO_MASKED_FORWARDED_HEADERS.each do |key|
|
|
133
|
+
masked[key] = @masked_ip if masked.key?(key)
|
|
134
|
+
end
|
|
135
|
+
# HTTP_FORWARDED (RFC 7239) carries the client IP in a structured `for=`
|
|
136
|
+
# token — and Otto reads it as an authoritative client-IP source in
|
|
137
|
+
# depth mode with trusted_proxy_header 'Forwarded'/'Both'. A wholesale
|
|
138
|
+
# swap would produce invalid Forwarded syntax, and geo resolution needs
|
|
139
|
+
# nothing from it, so drop it from the geo view entirely rather than
|
|
140
|
+
# leak the raw address to a custom resolver.
|
|
141
|
+
masked.delete('HTTP_FORWARDED')
|
|
142
|
+
masked
|
|
143
|
+
end
|
|
144
|
+
|
|
93
145
|
# Anonymize user agent string by removing version numbers and build identifiers
|
|
94
146
|
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
# browser/OS info.
|
|
147
|
+
# Delegates to the public {UserAgentPrivacy.anonymize} so there is a single
|
|
148
|
+
# source of truth for the reduction (removes version numbers and build
|
|
149
|
+
# identifiers, preserving browser/OS info) shared with downstream consumers.
|
|
98
150
|
#
|
|
99
151
|
# @param ua [String, nil] User agent string
|
|
100
152
|
# @return [String, nil] Anonymized user agent or nil
|
|
101
153
|
def anonymize_user_agent(ua)
|
|
102
|
-
|
|
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
|
|
154
|
+
UserAgentPrivacy.anonymize(ua)
|
|
119
155
|
end
|
|
120
156
|
|
|
121
157
|
# Anonymize referer URL
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# lib/otto/privacy/user_agent_privacy.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Privacy
|
|
7
|
+
# User-Agent anonymization utilities.
|
|
8
|
+
#
|
|
9
|
+
# Reduces a User-Agent string to a lower-entropy form by stripping build
|
|
10
|
+
# identifiers and version numbers and truncating, so it can be logged or
|
|
11
|
+
# stored for analytics without a high-entropy fingerprint while preserving
|
|
12
|
+
# browser/OS family information. This is the User-Agent analogue of
|
|
13
|
+
# {IPPrivacy} for IP addresses, exposed as a public surface so downstream
|
|
14
|
+
# consumers can reduce a UA outside of the full RedactedFingerprint /
|
|
15
|
+
# middleware flow without re-implementing (and drifting from) these regexes.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# UserAgentPrivacy.anonymize(
|
|
19
|
+
# 'Mozilla/5.0 (Windows NT 10.0) Chrome/119.0.0.0 Safari/537.36'
|
|
20
|
+
# )
|
|
21
|
+
# # => 'Mozilla/*.* (Windows NT *.*) Chrome/*.*.*.* Safari/*.*'
|
|
22
|
+
#
|
|
23
|
+
# @note Idempotent: re-anonymizing already-anonymized output is a no-op, so
|
|
24
|
+
# a UA reduced at the edge and one reduced again downstream agree.
|
|
25
|
+
class UserAgentPrivacy
|
|
26
|
+
# Default cap on the returned string, guarding against a DoS via a huge
|
|
27
|
+
# User-Agent header. Matches the length RedactedFingerprint has always
|
|
28
|
+
# applied.
|
|
29
|
+
DEFAULT_MAX_LENGTH = 500
|
|
30
|
+
|
|
31
|
+
# Anonymize a User-Agent string.
|
|
32
|
+
#
|
|
33
|
+
# Removes build identifiers (e.g. +Build/MRA58N+) and version numbers
|
|
34
|
+
# (+*.*.*.*+, +*.*.*+, +*.*+; dot- or underscore-separated), then
|
|
35
|
+
# truncates to +max_length+. Browser/OS family text is preserved -- the
|
|
36
|
+
# point is a partial, not a full redaction.
|
|
37
|
+
#
|
|
38
|
+
# Build identifiers are stripped BEFORE versions: if versions went first,
|
|
39
|
+
# a token like +Build/MPJ24.139-64+ would become +Build/MPJ*.*-64+ and the
|
|
40
|
+
# build regex (which matches only +[\w.-]+) would no longer catch it.
|
|
41
|
+
#
|
|
42
|
+
# @param ua [String, nil] the raw User-Agent string.
|
|
43
|
+
# @param max_length [Integer] maximum length of the returned string.
|
|
44
|
+
# @return [String, nil] the anonymized UA, or nil for nil/empty input.
|
|
45
|
+
def self.anonymize(ua, max_length: DEFAULT_MAX_LENGTH)
|
|
46
|
+
return nil if ua.nil? || ua.empty?
|
|
47
|
+
|
|
48
|
+
# Remove build identifiers (e.g., Build/MRA58N, Build/MPJ24.139-64).
|
|
49
|
+
# Must run BEFORE version stripping (see method note).
|
|
50
|
+
anonymized = ua.gsub(%r{Build/[\w.-]+}, 'Build/*')
|
|
51
|
+
|
|
52
|
+
# Remove version patterns (*.*.*.*, *.*.*, *.*), longest first.
|
|
53
|
+
# Support both dot and underscore separators (e.g. 10.15.7 and 10_15_7).
|
|
54
|
+
anonymized = anonymized
|
|
55
|
+
.gsub(/\d+[._]\d+[._]\d+[._]\d+/, '*.*.*.*')
|
|
56
|
+
.gsub(/\d+[._]\d+[._]\d+/, '*.*.*')
|
|
57
|
+
.gsub(/\d+[._]\d+/, '*.*')
|
|
58
|
+
|
|
59
|
+
# Truncate if too long (prevent DoS via huge UA strings).
|
|
60
|
+
anonymized.length > max_length ? anonymized[0...max_length] : anonymized
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/otto/privacy.rb
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
require_relative 'privacy/core'
|
|
6
6
|
require_relative 'privacy/config'
|
|
7
7
|
require_relative 'privacy/ip_privacy'
|
|
8
|
+
require_relative 'privacy/user_agent_privacy'
|
|
8
9
|
require_relative 'privacy/geo_resolver'
|
|
9
10
|
require_relative 'privacy/redacted_fingerprint'
|
|
10
11
|
|
|
@@ -17,7 +18,9 @@ require_relative 'privacy/redacted_fingerprint'
|
|
|
17
18
|
# Features:
|
|
18
19
|
# - Configurable IP masking (1 or 2 octets for IPv4, 80 or 96 bits for IPv6)
|
|
19
20
|
# - Daily-rotating IP hashing for session correlation without tracking
|
|
20
|
-
# - Geo-location resolution (country-level only
|
|
21
|
+
# - Geo-location resolution (country-level only): a configurable trusted header,
|
|
22
|
+
# built-in CDN provider headers, an optional local MaxMind-format (.mmdb)
|
|
23
|
+
# database looked up on the masked IP, or a custom resolver
|
|
21
24
|
# - User agent anonymization (removes version numbers)
|
|
22
25
|
#
|
|
23
26
|
# Privacy is ENABLED BY DEFAULT. To disable:
|