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
data/lib/otto/privacy/config.rb
CHANGED
|
@@ -27,8 +27,30 @@ class Otto
|
|
|
27
27
|
class Config
|
|
28
28
|
include Otto::Core::Freezable
|
|
29
29
|
|
|
30
|
+
# Named privacy profiles: validated presets over the individual knobs,
|
|
31
|
+
# so a deployment's observability posture is declared in one reviewable
|
|
32
|
+
# word instead of inferred from knob combinations.
|
|
33
|
+
#
|
|
34
|
+
# - :anonymous — mask every IP, including private/localhost. For
|
|
35
|
+
# deployments where even internal addresses are treated as PII.
|
|
36
|
+
# - :masked — the default posture: public IPs masked, private and
|
|
37
|
+
# localhost exempt (development-friendly privacy-by-default).
|
|
38
|
+
# - :audit — privacy disabled: real IPs flow to env and logs. For
|
|
39
|
+
# private/compliance environments where granular attributability
|
|
40
|
+
# supersedes IP privacy; retention responsibility transfers to the
|
|
41
|
+
# operator.
|
|
42
|
+
#
|
|
43
|
+
# Note the axis this controls: what PERSISTS observably (env keys, logs,
|
|
44
|
+
# fingerprints). Precise ephemeral matching against the unmasked IP does
|
|
45
|
+
# not require :audit — see EnvKeys::IP_MATCH, available in every profile.
|
|
46
|
+
PROFILES = {
|
|
47
|
+
anonymous: { disabled: false, mask_private_ips: true }.freeze,
|
|
48
|
+
masked: { disabled: false, mask_private_ips: false }.freeze,
|
|
49
|
+
audit: { disabled: true }.freeze,
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
30
52
|
attr_accessor :octet_precision, :hash_rotation_period, :geo_enabled, :mask_private_ips
|
|
31
|
-
attr_reader :disabled
|
|
53
|
+
attr_reader :disabled, :correlation_secret, :geo_header, :geo_db_path
|
|
32
54
|
|
|
33
55
|
# Class-level rotation key storage (mutable, not frozen with instances)
|
|
34
56
|
# This is stored at the class level so it persists across frozen config instances
|
|
@@ -49,16 +71,227 @@ class Otto
|
|
|
49
71
|
# @option options [Integer] :octet_precision Number of trailing octets to mask (1 or 2, default: 1)
|
|
50
72
|
# @option options [Integer] :hash_rotation_period Seconds between key rotation (default: 86400)
|
|
51
73
|
# @option options [Boolean] :geo_enabled Enable geo-location resolution (default: true)
|
|
74
|
+
# @option options [String] :geo_header Trusted, app-configured request header to read the
|
|
75
|
+
# country code from FIRST (before the built-in CDN provider headers). Accepts either
|
|
76
|
+
# the HTTP form ('X-Client-Country') or the Rack CGI form ('HTTP_X_CLIENT_COUNTRY');
|
|
77
|
+
# both canonicalize to the 'HTTP_*' env key. Default nil (no app-configured header).
|
|
78
|
+
# @option options [String] :geo_db_path Filesystem path to a MaxMind-format (.mmdb)
|
|
79
|
+
# country database used as the local IP->country fallback (looked up on the already
|
|
80
|
+
# MASKED IP). Requires the 'maxmind-db' gem. A bad/unreadable path raises at boot,
|
|
81
|
+
# not per-request. Default nil (no local database fallback).
|
|
82
|
+
# @option options [#get] :geo_db_reader Bring-your-own MMDB reader (any object responding
|
|
83
|
+
# to #get, e.g. a MaxMind::DB or a compatible reader). Overrides :geo_db_path when set,
|
|
84
|
+
# so the reader choice stays independent of Otto. Default nil.
|
|
52
85
|
# @option options [Boolean] :disabled Disable privacy entirely (default: false)
|
|
53
86
|
# @option options [Boolean] :mask_private_ips Mask private/localhost IPs (default: false)
|
|
87
|
+
# @option options [String] :correlation_secret A secret string that turns
|
|
88
|
+
# on IP correlation. Default nil, meaning off.
|
|
89
|
+
#
|
|
90
|
+
# It answers one question: "are these two requests, maybe months apart,
|
|
91
|
+
# from the same visitor?" — without your app ever seeing the real IP.
|
|
92
|
+
#
|
|
93
|
+
# Otto masks each IP before your app runs (203.0.113.42 becomes
|
|
94
|
+
# 203.0.113.0), which is too coarse to tell visitors apart. When a secret
|
|
95
|
+
# is set, Otto also fingerprints the full IP, before masking, and hands
|
|
96
|
+
# your app just the fingerprint as req.ip_correlation_hash. The same IP
|
|
97
|
+
# always produces the same fingerprint, and it can't be turned back into
|
|
98
|
+
# an IP without the secret.
|
|
99
|
+
#
|
|
100
|
+
# Keep the secret stable — changing it changes every fingerprint. An empty
|
|
101
|
+
# string is rejected, because an empty secret would let anyone reverse the
|
|
102
|
+
# fingerprint back to an IP.
|
|
54
103
|
# @option options [Redis] :redis Optional Redis connection for multi-server environments
|
|
104
|
+
# @option options [Symbol, String] :profile Named privacy profile (:anonymous,
|
|
105
|
+
# :masked, or :audit) applied as a preset; any other explicitly passed
|
|
106
|
+
# option overrides the preset. See {PROFILES}.
|
|
55
107
|
def initialize(options = {})
|
|
108
|
+
options = self.class.profile_presets(options[:profile]).merge(options) unless options[:profile].nil?
|
|
56
109
|
@octet_precision = options.fetch(:octet_precision, 1)
|
|
57
110
|
@hash_rotation_period = options.fetch(:hash_rotation_period, 86_400) # 24 hours
|
|
58
111
|
@geo_enabled = options.fetch(:geo_enabled, true)
|
|
59
112
|
@disabled = options.fetch(:disabled, false) # Enabled by default (privacy-by-default)
|
|
60
113
|
@mask_private_ips = options.fetch(:mask_private_ips, false) # Don't mask private/localhost by default
|
|
114
|
+
self.correlation_secret = options.fetch(:correlation_secret, nil) # Opt-in stable IP-correlation secret
|
|
61
115
|
@redis = options[:redis] # Optional Redis connection for multi-server environments
|
|
116
|
+
|
|
117
|
+
# Geo-location fallback configuration (all opt-in, boot-time only).
|
|
118
|
+
@geo_db_reader = nil # effective MMDB reader (built from path or injected)
|
|
119
|
+
@geo_db_override = nil # reader injected via geo_db_reader= (wins over path)
|
|
120
|
+
self.geo_header = options[:geo_header] # canonicalized to an HTTP_* env key (or nil)
|
|
121
|
+
self.geo_db_reader = options[:geo_db_reader] if options.key?(:geo_db_reader)
|
|
122
|
+
@geo_db_path = normalize_geo_db_path(options[:geo_db_path])
|
|
123
|
+
load_geo_database! # build/attach the reader now so a bad path fails at boot
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Set the stable correlation secret, validating its type up front.
|
|
127
|
+
#
|
|
128
|
+
# nil or an empty string mean "correlation hash disabled" (see
|
|
129
|
+
# IPPrivacyMiddleware#correlation_hash — an empty key is never used to
|
|
130
|
+
# hash). Any other non-String is a configuration error: without this
|
|
131
|
+
# guard it would surface far from its cause, as a NoMethodError on
|
|
132
|
+
# `#empty?` deep inside per-request middleware. Fail fast here instead,
|
|
133
|
+
# at the point of misconfiguration, with a message that names the type.
|
|
134
|
+
#
|
|
135
|
+
# @param value [String, nil] stable secret, or nil/"" to disable
|
|
136
|
+
# @raise [ArgumentError] if value is neither a String nor nil
|
|
137
|
+
def correlation_secret=(value)
|
|
138
|
+
unless value.nil? || value.is_a?(String)
|
|
139
|
+
raise ArgumentError, "correlation_secret must be a String or nil, got: #{value.class}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
@correlation_secret = value
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Set the trusted, app-configured geo header.
|
|
146
|
+
#
|
|
147
|
+
# Canonicalizes to a Rack CGI env key ('HTTP_*'): 'X-Client-Country',
|
|
148
|
+
# 'x-client-country' and 'HTTP_X_CLIENT_COUNTRY' all become
|
|
149
|
+
# 'HTTP_X_CLIENT_COUNTRY'. nil / blank clears it. Rack 3's lowercase rule
|
|
150
|
+
# applies to RESPONSE headers; request headers remain 'HTTP_*' env keys,
|
|
151
|
+
# so this is the correct form to read from the request env.
|
|
152
|
+
#
|
|
153
|
+
# @param value [String, nil] header name in either HTTP or CGI form
|
|
154
|
+
def geo_header=(value)
|
|
155
|
+
@geo_header = self.class.canonicalize_geo_header(value)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Set the geo database path (MMDB file) used for the local fallback.
|
|
159
|
+
#
|
|
160
|
+
# Does not build the reader on its own — call {#load_geo_database!} (which
|
|
161
|
+
# {Otto::Privacy::Core#configure_ip_privacy} does for you) so a bad path
|
|
162
|
+
# fails at boot rather than per-request.
|
|
163
|
+
#
|
|
164
|
+
# @param value [String, nil] filesystem path to a .mmdb file, or nil
|
|
165
|
+
def geo_db_path=(value)
|
|
166
|
+
@geo_db_path = normalize_geo_db_path(value)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Inject a ready-made MMDB reader (any object responding to #get).
|
|
170
|
+
#
|
|
171
|
+
# This keeps the reader choice independent of Otto (MaxMind::DB, yhirose's
|
|
172
|
+
# maxminddb, or a custom object all work) and is the seam used by tests.
|
|
173
|
+
# When set, it takes precedence over {#geo_db_path}. Passing nil clears the
|
|
174
|
+
# override. Takes effect on the next {#load_geo_database!}.
|
|
175
|
+
#
|
|
176
|
+
# @param reader [#get, nil] MMDB-compatible reader, or nil to clear
|
|
177
|
+
# @raise [ArgumentError] if reader does not respond to :get
|
|
178
|
+
def geo_db_reader=(reader)
|
|
179
|
+
unless reader.nil? || reader.respond_to?(:get)
|
|
180
|
+
raise ArgumentError, "geo_db_reader must respond to :get, got: #{reader.class}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
@geo_db_override = reader
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The effective MMDB reader for this config, or nil.
|
|
187
|
+
#
|
|
188
|
+
# Returns nil when geo is disabled (so `geo: false` consults no database
|
|
189
|
+
# even if one was previously configured) or when neither a reader override
|
|
190
|
+
# nor a database path is set.
|
|
191
|
+
#
|
|
192
|
+
# The reader is a plain instance variable — no class-level store. A
|
|
193
|
+
# MaxMind::DB reader computes its IPv4 start node eagerly at construction
|
|
194
|
+
# and performs no instance mutation on #get, so it is thread-safe under
|
|
195
|
+
# concurrency and unaffected by the shallow freeze deep_freeze! applies to
|
|
196
|
+
# it. That removes the only reason to hold it off-instance, and avoids an
|
|
197
|
+
# unbounded, never-evicted process-lifetime cache — important for a
|
|
198
|
+
# long-running server.
|
|
199
|
+
#
|
|
200
|
+
# @return [#get, nil] the reader, or nil
|
|
201
|
+
def geo_db_reader
|
|
202
|
+
@geo_enabled ? @geo_db_reader : nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Build/attach the geo database reader for the current configuration.
|
|
206
|
+
#
|
|
207
|
+
# Boot-time only. Resolves the effective reader (injected override wins
|
|
208
|
+
# over a path). A String path is opened eagerly here so an unreadable path
|
|
209
|
+
# or a missing 'maxmind-db' gem raises now, at configuration time, rather
|
|
210
|
+
# than on the first request that needs a lookup. When geo is disabled, no
|
|
211
|
+
# database is loaded and no reader is retained.
|
|
212
|
+
#
|
|
213
|
+
# @return [void]
|
|
214
|
+
# @raise [ArgumentError] if the path is unreadable or maxmind-db is absent
|
|
215
|
+
def load_geo_database!
|
|
216
|
+
@geo_db_reader = nil
|
|
217
|
+
return unless @geo_enabled
|
|
218
|
+
|
|
219
|
+
@geo_db_reader =
|
|
220
|
+
if @geo_db_override
|
|
221
|
+
@geo_db_override
|
|
222
|
+
elsif @geo_db_path
|
|
223
|
+
build_maxmind_reader(@geo_db_path)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Look up the preset hash for a named profile, failing fast on typos.
|
|
228
|
+
#
|
|
229
|
+
# @param profile [Symbol, String] one of the {PROFILES} keys
|
|
230
|
+
# @return [Hash] frozen preset hash
|
|
231
|
+
# @raise [ArgumentError] for an unknown profile name or an un-nameable type
|
|
232
|
+
def self.profile_presets(profile)
|
|
233
|
+
# Neither Integer nor NilClass responds to #to_sym, so an unguarded
|
|
234
|
+
# conversion raises NoMethodError for `profile: 123` or an explicit
|
|
235
|
+
# `profile: nil` — an opaque failure inconsistent with the ArgumentError
|
|
236
|
+
# the rest of this class raises for bad input (cf. correlation_secret=).
|
|
237
|
+
unless profile.respond_to?(:to_sym)
|
|
238
|
+
raise ArgumentError,
|
|
239
|
+
"Privacy profile must be a Symbol or String, got: #{profile.class}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
PROFILES.fetch(profile.to_sym) do
|
|
243
|
+
raise ArgumentError,
|
|
244
|
+
"Unknown privacy profile: #{profile.inspect} (valid: #{PROFILES.keys.join(', ')})"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Apply a named privacy profile's presets to this config.
|
|
249
|
+
#
|
|
250
|
+
# Sets only the knobs the profile names (see {PROFILES}); other settings
|
|
251
|
+
# (octet_precision, geo, correlation_secret, ...) are untouched.
|
|
252
|
+
#
|
|
253
|
+
# Presets are applied, not reset: a knob a profile does not name keeps its
|
|
254
|
+
# previous value. Switching :anonymous -> :audit therefore leaves
|
|
255
|
+
# mask_private_ips true, because :audit names only `disabled`. That is
|
|
256
|
+
# inert rather than wrong — `disabled` short-circuits privacy_enabled?
|
|
257
|
+
# before mask_private_ips is ever read, and #profile below tests @disabled
|
|
258
|
+
# first, so the derived label stays accurate. Switching on to :masked
|
|
259
|
+
# re-sets both knobs explicitly. Only surprising if you read the raw ivars.
|
|
260
|
+
#
|
|
261
|
+
# @param profile [Symbol, String] :anonymous, :masked, or :audit
|
|
262
|
+
# @raise [ArgumentError] for an unknown profile name
|
|
263
|
+
def profile=(profile)
|
|
264
|
+
presets = self.class.profile_presets(profile)
|
|
265
|
+
@disabled = presets[:disabled] if presets.key?(:disabled)
|
|
266
|
+
@mask_private_ips = presets[:mask_private_ips] if presets.key?(:mask_private_ips)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The profile the current knob state corresponds to.
|
|
270
|
+
#
|
|
271
|
+
# Derived from the live settings rather than remembering the last
|
|
272
|
+
# `profile=` call, so manual knob changes can never leave a stale label:
|
|
273
|
+
# what this returns is always what the config actually does.
|
|
274
|
+
#
|
|
275
|
+
# @return [Symbol] :audit, :anonymous, or :masked
|
|
276
|
+
def profile
|
|
277
|
+
return :audit if @disabled
|
|
278
|
+
return :anonymous if @mask_private_ips
|
|
279
|
+
|
|
280
|
+
:masked
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Canonicalize a geo header name to a Rack CGI env key ('HTTP_*').
|
|
284
|
+
#
|
|
285
|
+
# @param value [String, nil] header in HTTP ('X-Client-Country') or CGI form
|
|
286
|
+
# @return [String, nil] 'HTTP_*' env key, or nil for nil/blank input
|
|
287
|
+
def self.canonicalize_geo_header(value)
|
|
288
|
+
return nil if value.nil?
|
|
289
|
+
|
|
290
|
+
key = value.to_s.strip
|
|
291
|
+
return nil if key.empty?
|
|
292
|
+
|
|
293
|
+
key = key.upcase.tr('-', '_')
|
|
294
|
+
key.start_with?('HTTP_') ? key : "HTTP_#{key}"
|
|
62
295
|
end
|
|
63
296
|
|
|
64
297
|
# Check if privacy is enabled
|
|
@@ -124,13 +357,58 @@ class Otto
|
|
|
124
357
|
raise ArgumentError, "octet_precision must be 1 or 2, got: #{@octet_precision}" unless [1,
|
|
125
358
|
2].include?(@octet_precision)
|
|
126
359
|
|
|
127
|
-
|
|
360
|
+
# Type check before the numeric comparison: a non-Numeric value (false,
|
|
361
|
+
# a String from unparsed config, ...) would otherwise surface as
|
|
362
|
+
# NoMethodError/ArgumentError from #<, not a clear configuration error.
|
|
363
|
+
return if @hash_rotation_period.is_a?(Numeric) && @hash_rotation_period >= 60
|
|
128
364
|
|
|
129
|
-
raise ArgumentError,
|
|
365
|
+
raise ArgumentError,
|
|
366
|
+
"hash_rotation_period must be at least 60 seconds, got: #{@hash_rotation_period.inspect}"
|
|
130
367
|
end
|
|
131
368
|
|
|
132
369
|
private
|
|
133
370
|
|
|
371
|
+
# Normalize a geo_db_path option to a non-empty String or nil.
|
|
372
|
+
#
|
|
373
|
+
# @param value [String, nil] raw path option
|
|
374
|
+
# @return [String, nil]
|
|
375
|
+
def normalize_geo_db_path(value)
|
|
376
|
+
return nil if value.nil?
|
|
377
|
+
|
|
378
|
+
path = value.to_s.strip
|
|
379
|
+
path.empty? ? nil : path
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Open an MMDB file into an in-memory reader, failing fast on problems.
|
|
383
|
+
#
|
|
384
|
+
# The 'maxmind-db' gem is an OPTIONAL dependency: it is required lazily
|
|
385
|
+
# here, only when a database path is actually configured, so Otto stays
|
|
386
|
+
# dependency-light for the (common) header-only geo setups. Callers who
|
|
387
|
+
# prefer a different reader can inject one via {#geo_db_reader=} and never
|
|
388
|
+
# trigger this path.
|
|
389
|
+
#
|
|
390
|
+
# @param path [String] filesystem path to a .mmdb file
|
|
391
|
+
# @return [MaxMind::DB] in-memory reader
|
|
392
|
+
# @raise [ArgumentError] if the path is unreadable or the gem is missing
|
|
393
|
+
def build_maxmind_reader(path)
|
|
394
|
+
raise ArgumentError, "geo_db_path is not readable: #{path.inspect}" unless File.readable?(path)
|
|
395
|
+
|
|
396
|
+
begin
|
|
397
|
+
require 'maxmind/db'
|
|
398
|
+
rescue LoadError
|
|
399
|
+
raise ArgumentError,
|
|
400
|
+
"geo_db_path is set (#{path.inspect}) but the 'maxmind-db' gem is not available. " \
|
|
401
|
+
"Add `gem 'maxmind-db'` to your Gemfile, or inject your own reader via " \
|
|
402
|
+
'configure_ip_privacy(geo_db_reader: ...).'
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
begin
|
|
406
|
+
MaxMind::DB.new(path, mode: MaxMind::DB::MODE_MEMORY)
|
|
407
|
+
rescue StandardError => e
|
|
408
|
+
raise ArgumentError, "Failed to open geo_db_path #{path.inspect}: #{e.class}: #{e.message}"
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
134
412
|
# Redis-based rotation key (atomic across multiple servers)
|
|
135
413
|
#
|
|
136
414
|
# Uses SET NX GET EX to atomically:
|
data/lib/otto/privacy/core.rb
CHANGED
|
@@ -50,8 +50,27 @@ class Otto
|
|
|
50
50
|
#
|
|
51
51
|
# @param octet_precision [Integer] Number of octets to mask (1 or 2, default: 1)
|
|
52
52
|
# @param hash_rotation [Integer] Seconds between key rotation (default: 86400)
|
|
53
|
-
# @param geo [Boolean] Enable geo-location resolution (default: true)
|
|
53
|
+
# @param geo [Boolean] Enable geo-location resolution (default: true). When
|
|
54
|
+
# false, geo short-circuits entirely: no headers are read and no database
|
|
55
|
+
# is loaded or consulted.
|
|
56
|
+
# @param geo_header [String] Trusted, app-configured request header checked
|
|
57
|
+
# FIRST for the country code (e.g. 'X-Client-Country'). Accepts the HTTP
|
|
58
|
+
# or 'HTTP_*' CGI form; both canonicalize to the env key. Pass '' to clear.
|
|
59
|
+
# @param geo_db_path [String] Path to a MaxMind-format (.mmdb) country
|
|
60
|
+
# database for the local IP->country fallback (looked up on the MASKED
|
|
61
|
+
# IP). Requires the optional 'maxmind-db' gem. A bad path raises at boot,
|
|
62
|
+
# not per-request. Pass '' to clear.
|
|
63
|
+
# @param geo_db_reader [#get] Bring-your-own MMDB reader (any object
|
|
64
|
+
# responding to #get); overrides geo_db_path. Omitted/nil leaves any
|
|
65
|
+
# existing reader unchanged; use geo: false to stop consulting a database.
|
|
54
66
|
# @param redis [Redis] Redis connection for multi-server atomic key generation
|
|
67
|
+
# @param correlation_secret [String] A secret string that turns on IP
|
|
68
|
+
# correlation: it lets you tell whether two requests, even months apart,
|
|
69
|
+
# came from the same visitor — without your app ever seeing the real IP.
|
|
70
|
+
# (Otto masks the IP before your app runs; with a secret set it also
|
|
71
|
+
# fingerprints the full IP into req.ip_correlation_hash, which can't be
|
|
72
|
+
# reversed to an IP without the secret.) Omit it to leave any existing
|
|
73
|
+
# secret unchanged; pass an empty string to turn the feature back off.
|
|
55
74
|
#
|
|
56
75
|
# @example Mask 2 octets instead of 1
|
|
57
76
|
# otto.configure_ip_privacy(octet_precision: 2)
|
|
@@ -62,20 +81,97 @@ class Otto
|
|
|
62
81
|
# @example Custom hash rotation
|
|
63
82
|
# otto.configure_ip_privacy(hash_rotation: 24.hours)
|
|
64
83
|
#
|
|
84
|
+
# @example Enable stable IP correlation (same visitor across days)
|
|
85
|
+
# otto.configure_ip_privacy(correlation_secret: ENV['IP_CORRELATION_SECRET'])
|
|
86
|
+
#
|
|
65
87
|
# @example Multi-server with Redis
|
|
66
88
|
# redis = Redis.new(url: ENV['REDIS_URL'])
|
|
67
89
|
# otto.configure_ip_privacy(redis: redis)
|
|
68
|
-
|
|
90
|
+
#
|
|
91
|
+
# @example Declare the observability posture for a compliance deployment
|
|
92
|
+
# otto.configure_ip_privacy(profile: :audit)
|
|
93
|
+
#
|
|
94
|
+
# @param profile [Symbol] Named privacy profile (:anonymous, :masked, or
|
|
95
|
+
# :audit) applied FIRST as a preset over disabled/mask_private_ips, so
|
|
96
|
+
# any other option in the same call overrides it. This declares the
|
|
97
|
+
# deployment's observability posture in one reviewable word; see
|
|
98
|
+
# Otto::Privacy::Config::PROFILES.
|
|
99
|
+
#
|
|
100
|
+
# rubocop:disable Metrics/ParameterLists -- a keyword-only configuration
|
|
101
|
+
# method; the options are self-documenting at the call site and grouping
|
|
102
|
+
# them into a hash would only obscure the supported settings.
|
|
103
|
+
def configure_ip_privacy(octet_precision: nil, hash_rotation: nil, geo: nil, redis: nil,
|
|
104
|
+
correlation_secret: nil, geo_header: nil, geo_db_path: nil,
|
|
105
|
+
geo_db_reader: nil, profile: nil)
|
|
106
|
+
# rubocop:enable Metrics/ParameterLists
|
|
69
107
|
ensure_not_frozen!
|
|
70
108
|
config = @security_config.ip_privacy_config
|
|
109
|
+
knobs = { profile: profile, octet_precision: octet_precision,
|
|
110
|
+
hash_rotation: hash_rotation, geo: geo,
|
|
111
|
+
correlation_secret: correlation_secret, redis: redis }
|
|
112
|
+
|
|
113
|
+
# Dry-run the assignments on a throwaway copy and validate the combined
|
|
114
|
+
# result there, so a rejected knob (octet_precision: 7 after a profile
|
|
115
|
+
# preset, say) raises before the live config has been touched — the
|
|
116
|
+
# call is all-or-nothing, never half-applied.
|
|
117
|
+
apply_privacy_knobs(config.dup, knobs).validate!
|
|
118
|
+
apply_privacy_knobs(config, knobs)
|
|
119
|
+
|
|
120
|
+
apply_geo_config(config, geo: geo, geo_header: geo_header,
|
|
121
|
+
geo_db_path: geo_db_path, geo_db_reader: geo_db_reader)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# Assign the non-geo privacy knobs onto a config.
|
|
127
|
+
#
|
|
128
|
+
# Every kwarg uses a nil guard: nil means "leave unchanged", any other
|
|
129
|
+
# value — including false or "" — is a real assignment that must either
|
|
130
|
+
# take effect or fail validation loudly. A truthiness guard would
|
|
131
|
+
# silently drop false (and, for correlation_secret, the explicit ""
|
|
132
|
+
# that disables the correlation hash).
|
|
133
|
+
#
|
|
134
|
+
# @param config [Otto::Privacy::Config] the config to mutate
|
|
135
|
+
# @param knobs [Hash] the non-geo keyword arguments from configure_ip_privacy
|
|
136
|
+
# @return [Otto::Privacy::Config] the same config, for chaining
|
|
137
|
+
# @api private
|
|
138
|
+
def apply_privacy_knobs(config, knobs)
|
|
139
|
+
config.profile = knobs[:profile] unless knobs[:profile].nil?
|
|
140
|
+
config.octet_precision = knobs[:octet_precision] unless knobs[:octet_precision].nil?
|
|
141
|
+
config.hash_rotation_period = knobs[:hash_rotation] unless knobs[:hash_rotation].nil?
|
|
142
|
+
config.geo_enabled = knobs[:geo] unless knobs[:geo].nil?
|
|
143
|
+
config.correlation_secret = knobs[:correlation_secret] unless knobs[:correlation_secret].nil?
|
|
144
|
+
config.instance_variable_set(:@redis, knobs[:redis]) unless knobs[:redis].nil?
|
|
145
|
+
config
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Apply the geo-fallback settings and (re)load the database when needed.
|
|
149
|
+
#
|
|
150
|
+
# nil means "leave unchanged"; '' clears a header or path. Any geo-affecting
|
|
151
|
+
# change triggers a boot-time (re)load so a bad geo_db_path fails here, not
|
|
152
|
+
# on the first request that needs a lookup.
|
|
153
|
+
#
|
|
154
|
+
# @param config [Otto::Privacy::Config] the privacy config to mutate
|
|
155
|
+
# @api private
|
|
156
|
+
def apply_geo_config(config, geo:, geo_header:, geo_db_path:, geo_db_reader:)
|
|
157
|
+
geo_touched = [geo, geo_header, geo_db_path, geo_db_reader].any? { |v| !v.nil? }
|
|
158
|
+
|
|
159
|
+
config.geo_header = geo_header unless geo_header.nil?
|
|
71
160
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
161
|
+
# A newly supplied reader or path replaces the other database source. A
|
|
162
|
+
# reader given in this call wins over a path (documented precedence); a
|
|
163
|
+
# path given on its own clears any prior injected reader so it actually
|
|
164
|
+
# takes effect — otherwise the stale override would silently shadow the
|
|
165
|
+
# new path (leaving lookups pointed at a closed/old reader).
|
|
166
|
+
if !geo_db_reader.nil?
|
|
167
|
+
config.geo_db_reader = geo_db_reader
|
|
168
|
+
config.geo_db_path = geo_db_path unless geo_db_path.nil?
|
|
169
|
+
elsif !geo_db_path.nil?
|
|
170
|
+
config.geo_db_reader = nil
|
|
171
|
+
config.geo_db_path = geo_db_path
|
|
172
|
+
end
|
|
76
173
|
|
|
77
|
-
|
|
78
|
-
config.validate!
|
|
174
|
+
config.load_geo_database! if geo_touched
|
|
79
175
|
end
|
|
80
176
|
end
|
|
81
177
|
end
|