otto 2.7.0 → 2.8.1
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/ruby-lint.yml +9 -8
- data/.rubocop.yml +2 -1
- data/.rubocop_todo.yml +1384 -0
- data/CHANGELOG.rst +76 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +5 -5
- data/docs/.gitignore +1 -0
- data/docs/enrichment.md +128 -0
- data/docs/geo-country.md +11 -2
- data/docs/migrating/v2.3.0.md +55 -22
- data/lib/otto/core/error_handler.rb +15 -2
- data/lib/otto/env_keys.rb +50 -12
- data/lib/otto/privacy/anonymizer_resolver.rb +138 -0
- data/lib/otto/privacy/asn_resolver.rb +149 -0
- data/lib/otto/privacy/config.rb +159 -11
- data/lib/otto/privacy/core.rb +64 -1
- data/lib/otto/privacy/redacted_fingerprint.rb +15 -2
- data/lib/otto/privacy.rb +9 -0
- data/lib/otto/request.rb +56 -8
- data/lib/otto/security/config.rb +44 -6
- data/lib/otto/security/csp/policy.rb +5 -3
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +34 -8
- data/lib/otto/version.rb +1 -1
- metadata +10 -3
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# lib/otto/privacy/anonymizer_resolver.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Privacy
|
|
7
|
+
# Anonymizer (Tor / VPN / proxy / hosting) classification for IP addresses
|
|
8
|
+
#
|
|
9
|
+
# Answers one question — "is this address a known anonymizing egress, and
|
|
10
|
+
# of what kind?" — as a single label a downstream allow/deny rule can
|
|
11
|
+
# compare directly. Database-only, and the ONLY thing that ever leaves
|
|
12
|
+
# this resolver is the label.
|
|
13
|
+
#
|
|
14
|
+
# ## Why this one reads the UNMASKED address
|
|
15
|
+
#
|
|
16
|
+
# Every other database lookup in Otto operates on the masked IP, because
|
|
17
|
+
# country and ASN networks are >= /24 and a masked address lands in the
|
|
18
|
+
# same network as the real one. Anonymizer data breaks that equivalence:
|
|
19
|
+
# providers list individual egress nodes at or near /32, so a /24-masked
|
|
20
|
+
# lookup answers a question about the node's *neighbours* rather than the
|
|
21
|
+
# node. That produces wrong verdicts in both directions — flagging a whole
|
|
22
|
+
# /24 because one host in it is an exit node, and missing the exit node
|
|
23
|
+
# itself. A signal that quietly lies is worse than no signal.
|
|
24
|
+
#
|
|
25
|
+
# So this resolver takes the real address and returns only a label, which
|
|
26
|
+
# is the same trade Otto already makes twice: {IPPrivacy.hash_ip} consumes
|
|
27
|
+
# the full IP to emit an opaque digest, and +env['otto.ip_match']+ closes
|
|
28
|
+
# over the full IP to emit a boolean. The invariant Otto defends is that a
|
|
29
|
+
# raw address is never persisted, serialized, or handed downstream — not
|
|
30
|
+
# that it is unreachable in-process. A label honours that invariant; a
|
|
31
|
+
# masked lookup here would honour the letter of it while breaking the
|
|
32
|
+
# feature.
|
|
33
|
+
#
|
|
34
|
+
# ## Reading the 'none' label
|
|
35
|
+
#
|
|
36
|
+
# 'none' means "the database was consulted and did not list this address".
|
|
37
|
+
# Anonymizer databases are allow-list-by-omission — an address absent from
|
|
38
|
+
# the file is simply not a known egress — so absence is a real answer, not
|
|
39
|
+
# a miss. It is NOT a positive assertion that the address is a residential
|
|
40
|
+
# user, and it is only as fresh as the database file. '**' is reserved for
|
|
41
|
+
# "no database, or the lookup failed" — genuinely no answer.
|
|
42
|
+
#
|
|
43
|
+
# @example Configuring
|
|
44
|
+
# otto.configure_ip_privacy(
|
|
45
|
+
# anonymizer: true,
|
|
46
|
+
# anonymizer_db_path: 'data/GeoIP2-Anonymous-IP.mmdb',
|
|
47
|
+
# )
|
|
48
|
+
#
|
|
49
|
+
# @example Reading
|
|
50
|
+
# req.anonymizer # => 'tor' | 'vpn' | 'none' | '**' | nil
|
|
51
|
+
# env['otto.privacy.anonymizer']
|
|
52
|
+
#
|
|
53
|
+
class AnonymizerResolver
|
|
54
|
+
# Returned when classification is enabled but no database answered.
|
|
55
|
+
UNKNOWN = '**'
|
|
56
|
+
|
|
57
|
+
# Returned when the database was consulted and did not list the address.
|
|
58
|
+
NONE = 'none'
|
|
59
|
+
|
|
60
|
+
# Database flag => label, in precedence order. An address can carry
|
|
61
|
+
# several flags at once (a Tor exit node hosted at a cloud provider sets
|
|
62
|
+
# both +is_tor_exit_node+ and +is_hosting_provider+); the FIRST match
|
|
63
|
+
# wins, so the most specific and most access-relevant classification is
|
|
64
|
+
# what surfaces. Ordering rationale: Tor and public proxies are
|
|
65
|
+
# deliberate anonymity, commercial VPNs next, then residential proxies
|
|
66
|
+
# (frequently abuse infrastructure), then hosting — which is merely "not
|
|
67
|
+
# an eyeball network" and the weakest signal of the set.
|
|
68
|
+
CLASSIFICATIONS = [
|
|
69
|
+
%w[is_tor_exit_node tor],
|
|
70
|
+
%w[is_public_proxy proxy],
|
|
71
|
+
%w[is_anonymous_vpn vpn],
|
|
72
|
+
%w[is_residential_proxy residential_proxy],
|
|
73
|
+
%w[is_hosting_provider hosting],
|
|
74
|
+
# Generic catch-all: the provider says "anonymous" without saying how.
|
|
75
|
+
# Last, so it never masks a specific classification above.
|
|
76
|
+
%w[is_anonymous anonymous],
|
|
77
|
+
].freeze
|
|
78
|
+
|
|
79
|
+
# Every label this resolver can emit, for consumers building zone rules.
|
|
80
|
+
LABELS = (CLASSIFICATIONS.map(&:last) + [NONE, UNKNOWN]).freeze
|
|
81
|
+
|
|
82
|
+
class << self
|
|
83
|
+
# Classify an IP address.
|
|
84
|
+
#
|
|
85
|
+
# @param ip [String] the UNMASKED client IP (see the class docs)
|
|
86
|
+
# @param config [Otto::Privacy::Config, nil] privacy configuration
|
|
87
|
+
# @return [String] one of {LABELS}
|
|
88
|
+
def resolve(ip, config = nil)
|
|
89
|
+
return UNKNOWN if ip.nil? || ip.empty?
|
|
90
|
+
|
|
91
|
+
check_anonymizer_database(ip, config) || UNKNOWN
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Look the address up in the configured anonymizer database.
|
|
97
|
+
#
|
|
98
|
+
# No masking: see the class documentation for why this resolver is the
|
|
99
|
+
# one exception, and note the record is reduced to a label before it
|
|
100
|
+
# returns, so the address itself goes no further.
|
|
101
|
+
#
|
|
102
|
+
# @return [String, nil] a label, or nil to fall through to UNKNOWN
|
|
103
|
+
def check_anonymizer_database(ip, config)
|
|
104
|
+
reader = config&.anonymizer_db_reader
|
|
105
|
+
return nil unless reader
|
|
106
|
+
|
|
107
|
+
classify(reader.get(ip))
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
warn "AnonymizerResolver database lookup error: #{e.message}" if $DEBUG
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Reduce a database record to a single label.
|
|
114
|
+
#
|
|
115
|
+
# A nil record means "not listed", which for an anonymizer database is
|
|
116
|
+
# the meaningful answer NONE rather than a miss — these files record
|
|
117
|
+
# only flagged addresses. A non-Hash record is a malformed answer and
|
|
118
|
+
# falls through to UNKNOWN.
|
|
119
|
+
#
|
|
120
|
+
# @return [String, nil]
|
|
121
|
+
def classify(record)
|
|
122
|
+
return NONE if record.nil?
|
|
123
|
+
return nil unless record.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
match = CLASSIFICATIONS.find { |flag, _label| flagged?(record[flag]) }
|
|
126
|
+
match ? match.last : NONE
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# MaxMind omits these keys entirely when false, so absent and false are
|
|
130
|
+
# the same answer. Accept the string forms too, since MMDB builds from
|
|
131
|
+
# other vendors sometimes encode the flags as text.
|
|
132
|
+
def flagged?(value)
|
|
133
|
+
value == true || value.to_s == 'true' || value.to_s == '1'
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# lib/otto/privacy/asn_resolver.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Privacy
|
|
7
|
+
# Autonomous System Number (ASN) resolution for IP addresses
|
|
8
|
+
#
|
|
9
|
+
# Provides the network operator an address belongs to, as a privacy-safe
|
|
10
|
+
# label a downstream allow/deny rule can compare directly. Resolution is
|
|
11
|
+
# database-only and operates on Otto's already-MASKED IP, exactly as
|
|
12
|
+
# {GeoResolver}'s database fallback does — the unmasked address never
|
|
13
|
+
# reaches this resolver.
|
|
14
|
+
#
|
|
15
|
+
# Resolution order (first hit wins), when a privacy Config is supplied:
|
|
16
|
+
# 1. Local MMDB lookup, masked before lookup (Config#asn_db_reader)
|
|
17
|
+
# 2. '**' (unknown)
|
|
18
|
+
#
|
|
19
|
+
# Resolution is honest: when no database resolves an ASN, the answer is
|
|
20
|
+
# '**' — never a guess. A caller can therefore distinguish three states:
|
|
21
|
+
# nil (ASN resolution is switched off), '**' (on, but no answer), and a
|
|
22
|
+
# real label.
|
|
23
|
+
#
|
|
24
|
+
# ## Why database-only
|
|
25
|
+
#
|
|
26
|
+
# Unlike country, no CDN publishes a client-ASN header with meaningful
|
|
27
|
+
# deployment, so there is no header tier to trust. Staying database-only
|
|
28
|
+
# also keeps ASN clear of the geo_header/trusted_proxy_depth boot conflict
|
|
29
|
+
# ({Otto::Security::Config::GEO_HEADER_DEPTH_CONFLICT_MESSAGE}): there is
|
|
30
|
+
# no header to be silently ignored under count-based proxy trust.
|
|
31
|
+
#
|
|
32
|
+
# ## Masking and accuracy
|
|
33
|
+
#
|
|
34
|
+
# IPv4 BGP routes are not announced longer than /24, so a /24-masked
|
|
35
|
+
# address lands in the same announced prefix — and therefore the same
|
|
36
|
+
# ASN — as the real one. That equivalence is what makes a masked lookup
|
|
37
|
+
# honest here, and it is weaker than it looks for IPv6: at
|
|
38
|
+
# +octet_precision: 1+ Otto zeroes the last 80 bits (a /48), which is
|
|
39
|
+
# coarser than many IPv6 announcements. Treat IPv6 ASN as best-effort.
|
|
40
|
+
#
|
|
41
|
+
# @example Configuring
|
|
42
|
+
# otto.configure_ip_privacy(asn: true, asn_db_path: 'data/GeoLite2-ASN.mmdb')
|
|
43
|
+
#
|
|
44
|
+
# @example Reading
|
|
45
|
+
# req.asn # => 'AS15169' | '**' | nil
|
|
46
|
+
# env['otto.privacy.asn']
|
|
47
|
+
#
|
|
48
|
+
class AsnResolver
|
|
49
|
+
# Returned when ASN resolution is enabled but nothing resolved. Shared
|
|
50
|
+
# spelling with {GeoResolver::UNKNOWN} so consumers can treat every
|
|
51
|
+
# privacy label the same way.
|
|
52
|
+
UNKNOWN = '**'
|
|
53
|
+
|
|
54
|
+
# Reserved ASNs that carry no operator meaning: 0 is "reserved by the
|
|
55
|
+
# IANA" (RFC 7607) and 23456 is the AS_TRANS placeholder a 2-byte-only
|
|
56
|
+
# speaker substitutes for a 4-byte ASN (RFC 6793). A database that
|
|
57
|
+
# returns either has told us nothing, so both resolve to UNKNOWN rather
|
|
58
|
+
# than being dressed up as an answer.
|
|
59
|
+
RESERVED = [0, 23_456].freeze
|
|
60
|
+
|
|
61
|
+
# Highest assignable ASN; 4_294_967_295 is reserved (RFC 7300).
|
|
62
|
+
MAX_ASN = 4_294_967_294
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Resolve an ASN label for an IP address.
|
|
66
|
+
#
|
|
67
|
+
# @param ip [String] the ALREADY-MASKED client IP
|
|
68
|
+
# @param config [Otto::Privacy::Config, nil] privacy configuration
|
|
69
|
+
# @return [String] 'AS<number>' or {UNKNOWN}
|
|
70
|
+
def resolve(ip, config = nil)
|
|
71
|
+
return UNKNOWN if ip.nil? || ip.empty?
|
|
72
|
+
|
|
73
|
+
check_asn_database(ip, config) || UNKNOWN
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Look the masked IP up in the configured ASN database.
|
|
79
|
+
#
|
|
80
|
+
# The reader is any object responding to +#get(ip)+. A database read
|
|
81
|
+
# must never crash a request, so every StandardError falls through to
|
|
82
|
+
# "unknown" — the same posture {GeoResolver.check_geo_database} takes.
|
|
83
|
+
#
|
|
84
|
+
# @return [String, nil] 'AS<number>', or nil to fall through
|
|
85
|
+
def check_asn_database(ip, config)
|
|
86
|
+
reader = config&.asn_db_reader
|
|
87
|
+
return nil unless reader
|
|
88
|
+
|
|
89
|
+
# Re-mask defensively: masking is idempotent, so this costs nothing
|
|
90
|
+
# for an already-masked address and closes the hole if a caller ever
|
|
91
|
+
# hands us a raw one directly.
|
|
92
|
+
lookup_ip = IPPrivacy.mask_ip(ip, config.octet_precision) || ip
|
|
93
|
+
format_asn(extract_db_asn(reader.get(lookup_ip)))
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
warn "AsnResolver database lookup error: #{e.message}" if $DEBUG
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Pull the AS number out of a database record.
|
|
100
|
+
#
|
|
101
|
+
# MaxMind's GeoLite2-ASN stores a flat +autonomous_system_number+ as a
|
|
102
|
+
# uint32. Some combined builds nest it under an 'asn' map, and a few
|
|
103
|
+
# emit the bare key 'asn'. Accept all three; anything else is not an
|
|
104
|
+
# answer.
|
|
105
|
+
#
|
|
106
|
+
# NOTE: the value is an Integer, not a String — the opposite of
|
|
107
|
+
# {GeoResolver.extract_db_country}'s terminal guard. Copying that
|
|
108
|
+
# method's String check here would discard every real hit.
|
|
109
|
+
#
|
|
110
|
+
# @return [Integer, nil]
|
|
111
|
+
def extract_db_asn(result)
|
|
112
|
+
return nil unless result.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
asn = result['asn']
|
|
115
|
+
number =
|
|
116
|
+
if asn.is_a?(Hash)
|
|
117
|
+
asn['autonomous_system_number'] || asn['number']
|
|
118
|
+
else
|
|
119
|
+
result['autonomous_system_number'] || asn
|
|
120
|
+
end
|
|
121
|
+
valid_asn?(number) ? number : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# @return [Boolean] whether the number is an assignable, meaningful ASN
|
|
125
|
+
def valid_asn?(number)
|
|
126
|
+
number.is_a?(Integer) &&
|
|
127
|
+
number.positive? &&
|
|
128
|
+
number <= MAX_ASN &&
|
|
129
|
+
!RESERVED.include?(number)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Render as the conventional 'AS<number>' text form.
|
|
133
|
+
#
|
|
134
|
+
# A String — never the bare Integer — because the whole resolution
|
|
135
|
+
# chain, and Otto::Request's env fallbacks, treat a falsey value as
|
|
136
|
+
# "keep looking". A label is also what a downstream zone rule compares
|
|
137
|
+
# against, and it leaves room for the '**' sentinel that an Integer
|
|
138
|
+
# representation has no way to express.
|
|
139
|
+
#
|
|
140
|
+
# @return [String, nil]
|
|
141
|
+
def format_asn(number)
|
|
142
|
+
return nil if number.nil?
|
|
143
|
+
|
|
144
|
+
"AS#{number}"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/otto/privacy/config.rb
CHANGED
|
@@ -24,6 +24,10 @@ class Otto
|
|
|
24
24
|
# config = Otto::Privacy::Config.new
|
|
25
25
|
# config.octet_precision = 2 # Mask 2 octets instead of 1
|
|
26
26
|
#
|
|
27
|
+
# rubocop:disable Metrics/ClassLength -- three parallel database
|
|
28
|
+
# configurations (geo, ASN, anonymizer) live here by design: each is a
|
|
29
|
+
# thin, symmetric writer/reader/loader trio, and splitting them into
|
|
30
|
+
# modules would hide the symmetry that makes them reviewable.
|
|
27
31
|
class Config
|
|
28
32
|
include Otto::Core::Freezable
|
|
29
33
|
|
|
@@ -49,8 +53,10 @@ class Otto
|
|
|
49
53
|
audit: { disabled: true }.freeze,
|
|
50
54
|
}.freeze
|
|
51
55
|
|
|
52
|
-
attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
|
|
53
|
-
|
|
56
|
+
attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips,
|
|
57
|
+
:asn_enabled, :anonymizer_enabled
|
|
58
|
+
attr_reader :disabled, :correlation_secret, :geo_header, :geo_db_path,
|
|
59
|
+
:asn_db_path, :anonymizer_db_path
|
|
54
60
|
|
|
55
61
|
# Class-level rotation key storage (mutable, not frozen with instances)
|
|
56
62
|
# This is stored at the class level so it persists across frozen config instances
|
|
@@ -82,6 +88,23 @@ class Otto
|
|
|
82
88
|
# @option options [#get] :geo_db_reader Bring-your-own MMDB reader (any object responding
|
|
83
89
|
# to #get, e.g. a MaxMind::DB or a compatible reader). Overrides :geo_db_path when set,
|
|
84
90
|
# so the reader choice stays independent of Otto. Default nil.
|
|
91
|
+
# @option options [Boolean] :asn_enabled Enable ASN resolution (default: FALSE — unlike
|
|
92
|
+
# :geo_enabled, this signal is opt-in, so a deployment that never asks for it pays
|
|
93
|
+
# nothing and no database is opened)
|
|
94
|
+
# @option options [String] :asn_db_path Filesystem path to a MaxMind-format (.mmdb) ASN
|
|
95
|
+
# database (looked up on the already MASKED IP, like the geo database). Requires the
|
|
96
|
+
# 'maxmind-db' gem. A bad/unreadable path raises at boot, not per-request. Default nil.
|
|
97
|
+
# @option options [#get] :asn_db_reader Bring-your-own MMDB reader for ASN lookups (any
|
|
98
|
+
# object responding to #get). Overrides :asn_db_path when set. Default nil.
|
|
99
|
+
# @option options [Boolean] :anonymizer_enabled Enable anonymizer (Tor/VPN/proxy/hosting)
|
|
100
|
+
# classification (default: FALSE — opt-in, same as :asn_enabled)
|
|
101
|
+
# @option options [String] :anonymizer_db_path Filesystem path to a MaxMind-format (.mmdb)
|
|
102
|
+
# anonymous-IP database. Looked up on the UNMASKED IP — anonymizer data lists individual
|
|
103
|
+
# egress nodes at /32, so a masked lookup would answer for the node's neighbours; only
|
|
104
|
+
# the resulting label leaves the resolver. Requires the 'maxmind-db' gem. A bad path
|
|
105
|
+
# raises at boot. Default nil.
|
|
106
|
+
# @option options [#get] :anonymizer_db_reader Bring-your-own MMDB reader for anonymizer
|
|
107
|
+
# lookups (any object responding to #get). Overrides :anonymizer_db_path. Default nil.
|
|
85
108
|
# @option options [Boolean] :disabled Disable privacy entirely (default: false)
|
|
86
109
|
# @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
|
|
87
110
|
# @option options [String] :correlation_secret A secret string that turns
|
|
@@ -119,8 +142,24 @@ class Otto
|
|
|
119
142
|
@geo_db_override = nil # reader injected via geo_db_reader= (wins over path)
|
|
120
143
|
self.geo_header = options[:geo_header] # canonicalized to an HTTP_* env key (or nil)
|
|
121
144
|
self.geo_db_reader = options[:geo_db_reader] if options.key?(:geo_db_reader)
|
|
122
|
-
@geo_db_path =
|
|
145
|
+
@geo_db_path = normalize_db_path(options[:geo_db_path])
|
|
123
146
|
load_geo_database! # build/attach the reader now so a bad path fails at boot
|
|
147
|
+
|
|
148
|
+
# ASN enrichment (opt-in, boot-time only). Same two-ivar shape as geo.
|
|
149
|
+
@asn_enabled = options.fetch(:asn_enabled, false)
|
|
150
|
+
@asn_db_reader = nil
|
|
151
|
+
@asn_db_override = nil
|
|
152
|
+
self.asn_db_reader = options[:asn_db_reader] if options.key?(:asn_db_reader)
|
|
153
|
+
@asn_db_path = normalize_db_path(options[:asn_db_path])
|
|
154
|
+
load_asn_database!
|
|
155
|
+
|
|
156
|
+
# Anonymizer classification (opt-in, boot-time only).
|
|
157
|
+
@anonymizer_enabled = options.fetch(:anonymizer_enabled, false)
|
|
158
|
+
@anonymizer_db_reader = nil
|
|
159
|
+
@anonymizer_db_override = nil
|
|
160
|
+
self.anonymizer_db_reader = options[:anonymizer_db_reader] if options.key?(:anonymizer_db_reader)
|
|
161
|
+
@anonymizer_db_path = normalize_db_path(options[:anonymizer_db_path])
|
|
162
|
+
load_anonymizer_database!
|
|
124
163
|
end
|
|
125
164
|
|
|
126
165
|
# Set the stable correlation secret, validating its type up front.
|
|
@@ -163,7 +202,42 @@ class Otto
|
|
|
163
202
|
#
|
|
164
203
|
# @param value [String, nil] filesystem path to a .mmdb file, or nil
|
|
165
204
|
def geo_db_path=(value)
|
|
166
|
-
@geo_db_path =
|
|
205
|
+
@geo_db_path = normalize_db_path(value)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Path to a MaxMind-format ASN database. See {#geo_db_path=}; the same
|
|
209
|
+
# boot-time contract applies — call {#load_asn_database!} to attach it.
|
|
210
|
+
#
|
|
211
|
+
# @param value [String, nil] filesystem path to a .mmdb file, or nil
|
|
212
|
+
def asn_db_path=(value)
|
|
213
|
+
@asn_db_path = normalize_db_path(value)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Path to a MaxMind-format anonymous-IP database. See {#geo_db_path=};
|
|
217
|
+
# call {#load_anonymizer_database!} to attach it.
|
|
218
|
+
#
|
|
219
|
+
# @param value [String, nil] filesystem path to a .mmdb file, or nil
|
|
220
|
+
def anonymizer_db_path=(value)
|
|
221
|
+
@anonymizer_db_path = normalize_db_path(value)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Replace one enrichment database path only after its new reader has
|
|
225
|
+
# opened successfully. Used by the boot-time configuration path; direct
|
|
226
|
+
# callers should normally use Otto#configure_ip_privacy.
|
|
227
|
+
#
|
|
228
|
+
# @param prefix [:asn, :anonymizer] database source to replace
|
|
229
|
+
# @param value [String, nil] new database path
|
|
230
|
+
# @return [void]
|
|
231
|
+
# @raise [ArgumentError] if an enabled signal's path cannot be opened
|
|
232
|
+
# @api private
|
|
233
|
+
def replace_enrichment_database_path!(prefix, value)
|
|
234
|
+
path = normalize_db_path(value)
|
|
235
|
+
enabled = instance_variable_get(:"@#{prefix}_enabled")
|
|
236
|
+
reader = path && enabled ? build_maxmind_reader(path, option_name: "#{prefix}_db_path") : nil
|
|
237
|
+
|
|
238
|
+
instance_variable_set(:"@#{prefix}_db_path", path)
|
|
239
|
+
instance_variable_set(:"@#{prefix}_db_override", nil)
|
|
240
|
+
instance_variable_set(:"@#{prefix}_db_reader", reader)
|
|
167
241
|
end
|
|
168
242
|
|
|
169
243
|
# Inject a ready-made MMDB reader (any object responding to #get).
|
|
@@ -202,6 +276,45 @@ class Otto
|
|
|
202
276
|
@geo_enabled ? @geo_db_reader : nil
|
|
203
277
|
end
|
|
204
278
|
|
|
279
|
+
# Inject a ready-made MMDB reader for ASN lookups. See {#geo_db_reader=}.
|
|
280
|
+
#
|
|
281
|
+
# @param reader [#get, nil] MMDB-compatible reader, or nil to clear
|
|
282
|
+
# @raise [ArgumentError] if reader does not respond to :get
|
|
283
|
+
def asn_db_reader=(reader)
|
|
284
|
+
unless reader.nil? || reader.respond_to?(:get)
|
|
285
|
+
raise ArgumentError, "asn_db_reader must respond to :get, got: #{reader.class}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
@asn_db_override = reader
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# The effective ASN reader, or nil when ASN resolution is off.
|
|
292
|
+
#
|
|
293
|
+
# @return [#get, nil]
|
|
294
|
+
def asn_db_reader
|
|
295
|
+
@asn_enabled ? @asn_db_reader : nil
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Inject a ready-made MMDB reader for anonymizer lookups.
|
|
299
|
+
# See {#geo_db_reader=}.
|
|
300
|
+
#
|
|
301
|
+
# @param reader [#get, nil] MMDB-compatible reader, or nil to clear
|
|
302
|
+
# @raise [ArgumentError] if reader does not respond to :get
|
|
303
|
+
def anonymizer_db_reader=(reader)
|
|
304
|
+
unless reader.nil? || reader.respond_to?(:get)
|
|
305
|
+
raise ArgumentError, "anonymizer_db_reader must respond to :get, got: #{reader.class}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
@anonymizer_db_override = reader
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# The effective anonymizer reader, or nil when classification is off.
|
|
312
|
+
#
|
|
313
|
+
# @return [#get, nil]
|
|
314
|
+
def anonymizer_db_reader
|
|
315
|
+
@anonymizer_enabled ? @anonymizer_db_reader : nil
|
|
316
|
+
end
|
|
317
|
+
|
|
205
318
|
# Build/attach the geo database reader for the current configuration.
|
|
206
319
|
#
|
|
207
320
|
# Boot-time only. Resolves the effective reader (injected override wins
|
|
@@ -224,6 +337,39 @@ class Otto
|
|
|
224
337
|
end
|
|
225
338
|
end
|
|
226
339
|
|
|
340
|
+
# Build/attach the ASN database reader. See {#load_geo_database!} — same
|
|
341
|
+
# boot-time contract, same override-wins-over-path resolution.
|
|
342
|
+
#
|
|
343
|
+
# @return [void]
|
|
344
|
+
# @raise [ArgumentError] if the path is unreadable or maxmind-db is absent
|
|
345
|
+
def load_asn_database!
|
|
346
|
+
@asn_db_reader = nil
|
|
347
|
+
return unless @asn_enabled
|
|
348
|
+
|
|
349
|
+
@asn_db_reader =
|
|
350
|
+
if @asn_db_override
|
|
351
|
+
@asn_db_override
|
|
352
|
+
elsif @asn_db_path
|
|
353
|
+
build_maxmind_reader(@asn_db_path, option_name: 'asn_db_path')
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Build/attach the anonymizer database reader. See {#load_geo_database!}.
|
|
358
|
+
#
|
|
359
|
+
# @return [void]
|
|
360
|
+
# @raise [ArgumentError] if the path is unreadable or maxmind-db is absent
|
|
361
|
+
def load_anonymizer_database!
|
|
362
|
+
@anonymizer_db_reader = nil
|
|
363
|
+
return unless @anonymizer_enabled
|
|
364
|
+
|
|
365
|
+
@anonymizer_db_reader =
|
|
366
|
+
if @anonymizer_db_override
|
|
367
|
+
@anonymizer_db_override
|
|
368
|
+
elsif @anonymizer_db_path
|
|
369
|
+
build_maxmind_reader(@anonymizer_db_path, option_name: 'anonymizer_db_path')
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
227
373
|
# Look up the preset hash for a named profile, failing fast on typos.
|
|
228
374
|
#
|
|
229
375
|
# @param profile [Symbol, String] one of the {PROFILES} keys
|
|
@@ -368,11 +514,12 @@ class Otto
|
|
|
368
514
|
|
|
369
515
|
private
|
|
370
516
|
|
|
371
|
-
# Normalize a
|
|
517
|
+
# Normalize a database-path option to a non-empty String or nil. Shared by
|
|
518
|
+
# the geo, ASN and anonymizer paths — the rule is identical for all three.
|
|
372
519
|
#
|
|
373
520
|
# @param value [String, nil] raw path option
|
|
374
521
|
# @return [String, nil]
|
|
375
|
-
def
|
|
522
|
+
def normalize_db_path(value)
|
|
376
523
|
return nil if value.nil?
|
|
377
524
|
|
|
378
525
|
path = value.to_s.strip
|
|
@@ -390,22 +537,22 @@ class Otto
|
|
|
390
537
|
# @param path [String] filesystem path to a .mmdb file
|
|
391
538
|
# @return [MaxMind::DB] in-memory reader
|
|
392
539
|
# @raise [ArgumentError] if the path is unreadable or the gem is missing
|
|
393
|
-
def build_maxmind_reader(path)
|
|
394
|
-
raise ArgumentError, "
|
|
540
|
+
def build_maxmind_reader(path, option_name: 'geo_db_path')
|
|
541
|
+
raise ArgumentError, "#{option_name} is not readable: #{path.inspect}" unless File.readable?(path)
|
|
395
542
|
|
|
396
543
|
begin
|
|
397
544
|
require 'maxmind/db'
|
|
398
545
|
rescue LoadError
|
|
399
546
|
raise ArgumentError,
|
|
400
|
-
"
|
|
547
|
+
"#{option_name} is set (#{path.inspect}) but the 'maxmind-db' gem is not available. " \
|
|
401
548
|
"Add `gem 'maxmind-db'` to your Gemfile, or inject your own reader via " \
|
|
402
|
-
|
|
549
|
+
"configure_ip_privacy(#{option_name.sub('_path', '_reader')}: ...)."
|
|
403
550
|
end
|
|
404
551
|
|
|
405
552
|
begin
|
|
406
553
|
MaxMind::DB.new(path, mode: MaxMind::DB::MODE_MEMORY)
|
|
407
554
|
rescue StandardError => e
|
|
408
|
-
raise ArgumentError, "Failed to open
|
|
555
|
+
raise ArgumentError, "Failed to open #{option_name} #{path.inspect}: #{e.class}: #{e.message}"
|
|
409
556
|
end
|
|
410
557
|
end
|
|
411
558
|
|
|
@@ -475,5 +622,6 @@ class Otto
|
|
|
475
622
|
key
|
|
476
623
|
end
|
|
477
624
|
end
|
|
625
|
+
# rubocop:enable Metrics/ClassLength
|
|
478
626
|
end
|
|
479
627
|
end
|
data/lib/otto/privacy/core.rb
CHANGED
|
@@ -102,10 +102,23 @@ class Otto
|
|
|
102
102
|
# them into a hash would only obscure the supported settings.
|
|
103
103
|
def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil,
|
|
104
104
|
correlation_secret: nil, geo_header: nil, geo_db_path: nil,
|
|
105
|
-
geo_db_reader: nil, profile: nil
|
|
105
|
+
geo_db_reader: nil, profile: nil, asn: nil, asn_db_path: nil,
|
|
106
|
+
asn_db_reader: nil, anonymizer: nil, anonymizer_db_path: nil,
|
|
107
|
+
anonymizer_db_reader: nil)
|
|
106
108
|
# rubocop:enable Metrics/ParameterLists
|
|
107
109
|
ensure_not_frozen!
|
|
108
110
|
config = @security_config.ip_privacy_config
|
|
111
|
+
|
|
112
|
+
# Geo headers are honored only for peers matching enumerated CIDR
|
|
113
|
+
# matchers, never for count-trusted hops, so a geo_header configured
|
|
114
|
+
# alongside depth mode could never be consulted. Fail loud here
|
|
115
|
+
# (depth-then-geo order; the trusted_proxy_depth= setter catches
|
|
116
|
+
# geo-then-depth). A blank geo_header canonicalizes to nil ("clear"),
|
|
117
|
+
# which stays legal under depth.
|
|
118
|
+
if Otto::Privacy::Config.canonicalize_geo_header(geo_header) &&
|
|
119
|
+
@security_config.trusted_proxy_depth_mode?
|
|
120
|
+
raise ArgumentError, Otto::Security::Config::GEO_HEADER_DEPTH_CONFLICT_MESSAGE
|
|
121
|
+
end
|
|
109
122
|
knobs = { profile: profile, octet_precision: octet_precision,
|
|
110
123
|
hash_rotation: hash_rotation, geo: geo,
|
|
111
124
|
correlation_secret: correlation_secret, redis: redis }
|
|
@@ -119,6 +132,8 @@ class Otto
|
|
|
119
132
|
|
|
120
133
|
apply_geo_config(config, geo: geo, geo_header: geo_header,
|
|
121
134
|
geo_db_path: geo_db_path, geo_db_reader: geo_db_reader)
|
|
135
|
+
apply_enrichment_signal(config, :asn, asn, asn_db_path, asn_db_reader)
|
|
136
|
+
apply_enrichment_signal(config, :anonymizer, anonymizer, anonymizer_db_path, anonymizer_db_reader)
|
|
122
137
|
end
|
|
123
138
|
|
|
124
139
|
private
|
|
@@ -173,6 +188,54 @@ class Otto
|
|
|
173
188
|
|
|
174
189
|
config.load_geo_database! if geo_touched
|
|
175
190
|
end
|
|
191
|
+
|
|
192
|
+
# Apply one opt-in enrichment signal (ASN or anonymizer classification).
|
|
193
|
+
#
|
|
194
|
+
# Same contract as {#apply_geo_config}: nil means "leave unchanged", a
|
|
195
|
+
# reader supplied in this call wins over a path, and a path supplied on
|
|
196
|
+
# its own clears any prior injected reader so it actually takes effect.
|
|
197
|
+
# Any touched knob triggers a boot-time (re)load so a bad path fails
|
|
198
|
+
# here, not on the first request that needs a lookup.
|
|
199
|
+
#
|
|
200
|
+
# These deliberately live outside apply_privacy_knobs. That runs twice —
|
|
201
|
+
# once against a shallow config.dup for the dry-run validation — and a
|
|
202
|
+
# dup shares reader ivars with the original, so loading a database there
|
|
203
|
+
# would touch live state during what is supposed to be a rehearsal.
|
|
204
|
+
#
|
|
205
|
+
# @param config [Otto::Privacy::Config] the privacy config to mutate
|
|
206
|
+
# @param prefix [Symbol] :asn or :anonymizer, selecting the config members
|
|
207
|
+
# @api private
|
|
208
|
+
def apply_enrichment_signal(config, prefix, enabled, path, reader)
|
|
209
|
+
previous_enabled = config.public_send(:"#{prefix}_enabled")
|
|
210
|
+
config.public_send(:"#{prefix}_enabled=", enabled) unless enabled.nil?
|
|
211
|
+
source_replaced = replace_db_source?(config, prefix, path, reader)
|
|
212
|
+
config.public_send(:"load_#{prefix}_database!") if !source_replaced && [enabled, path, reader].any? { |v| !v.nil? }
|
|
213
|
+
rescue StandardError
|
|
214
|
+
# A path replacement is validated before it is committed, but restoring
|
|
215
|
+
# the enabled flag here also keeps a combined `enabled: ..., path: ...`
|
|
216
|
+
# call all-or-nothing when the replacement cannot be opened.
|
|
217
|
+
config.public_send(:"#{prefix}_enabled=", previous_enabled) unless previous_enabled.nil?
|
|
218
|
+
raise
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Reader-vs-path precedence for one enrichment signal, mirroring the geo
|
|
222
|
+
# branch in {#apply_geo_config}.
|
|
223
|
+
#
|
|
224
|
+
# @api private
|
|
225
|
+
def replace_db_source?(config, prefix, path, reader)
|
|
226
|
+
if !reader.nil?
|
|
227
|
+
config.public_send(:"#{prefix}_db_reader=", reader)
|
|
228
|
+
config.public_send(:"#{prefix}_db_path=", path) unless path.nil?
|
|
229
|
+
false
|
|
230
|
+
elsif !path.nil?
|
|
231
|
+
# Build the replacement before changing the active source. A failed
|
|
232
|
+
# path must leave the previous reader available for future requests.
|
|
233
|
+
config.replace_enrichment_database_path!(prefix, path)
|
|
234
|
+
true
|
|
235
|
+
else
|
|
236
|
+
false
|
|
237
|
+
end
|
|
238
|
+
end
|
|
176
239
|
end
|
|
177
240
|
end
|
|
178
241
|
end
|
|
@@ -22,8 +22,8 @@ class Otto
|
|
|
22
22
|
#
|
|
23
23
|
class RedactedFingerprint
|
|
24
24
|
attr_reader :session_id, :timestamp, :masked_ip, :hashed_ip,
|
|
25
|
-
:country, :
|
|
26
|
-
:request_method, :referer
|
|
25
|
+
:country, :asn, :anonymizer, :anonymized_ua,
|
|
26
|
+
:request_path, :request_method, :referer
|
|
27
27
|
|
|
28
28
|
# IP-bearing forwarded headers overwritten with the masked IP in the
|
|
29
29
|
# geo-resolution env view. Mirrors the set
|
|
@@ -60,6 +60,17 @@ class Otto
|
|
|
60
60
|
@country = if config.geo_enabled
|
|
61
61
|
GeoResolver.resolve(@masked_ip, geo_env(env), config, headers_trusted: geo_headers_trusted)
|
|
62
62
|
end
|
|
63
|
+
# ASN keeps the masked-IP contract: IPv4 routes are not announced longer
|
|
64
|
+
# than /24, so a masked address falls in the same announced prefix and
|
|
65
|
+
# therefore the same ASN.
|
|
66
|
+
@asn = AsnResolver.resolve(@masked_ip, config) if config.asn_enabled
|
|
67
|
+
# The anonymizer is the one lookup that gets the REAL address, for the
|
|
68
|
+
# same reason hashed_ip above does: masking would destroy the answer.
|
|
69
|
+
# Anonymizer databases list individual egress nodes at or near /32, so a
|
|
70
|
+
# /24-masked lookup would report on the node's neighbours instead of the
|
|
71
|
+
# node. Only the resulting label is retained — the address goes no
|
|
72
|
+
# further than the resolver, exactly as it goes no further than hash_ip.
|
|
73
|
+
@anonymizer = AnonymizerResolver.resolve(remote_ip, config) if config.anonymizer_enabled
|
|
63
74
|
@anonymized_ua = anonymize_user_agent(env['HTTP_USER_AGENT'])
|
|
64
75
|
@request_path = env['PATH_INFO']
|
|
65
76
|
@request_method = env['REQUEST_METHOD']
|
|
@@ -78,6 +89,8 @@ class Otto
|
|
|
78
89
|
masked_ip: @masked_ip,
|
|
79
90
|
hashed_ip: @hashed_ip,
|
|
80
91
|
country: @country,
|
|
92
|
+
asn: @asn,
|
|
93
|
+
anonymizer: @anonymizer,
|
|
81
94
|
anonymized_ua: @anonymized_ua,
|
|
82
95
|
request_method: @request_method,
|
|
83
96
|
request_path: @request_path,
|