otto 2.6.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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +1 -1
  3. data/.github/workflows/claude-code-review.yml +1 -1
  4. data/.github/workflows/claude.yml +1 -1
  5. data/.github/workflows/code-smells.yml +2 -2
  6. data/.github/workflows/release-gem.yml +1 -1
  7. data/.github/workflows/ruby-lint.yml +1 -1
  8. data/.github/workflows/yardoc.yml +1 -1
  9. data/.pre-commit-config.yaml +22 -5
  10. data/CHANGELOG.rst +218 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +12 -10
  13. data/README.md +13 -3
  14. data/docs/.gitignore +1 -0
  15. data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
  16. data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
  17. data/docs/geo-country.md +172 -0
  18. data/docs/reverse-proxy-network-services.md +19 -6
  19. data/examples/simple_geo_resolver.rb +38 -5
  20. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  21. data/lib/otto/core/middleware_stack.rb +72 -25
  22. data/lib/otto/env_keys.rb +32 -0
  23. data/lib/otto/logging_helpers.rb +50 -1
  24. data/lib/otto/mcp/rate_limiting.rb +5 -2
  25. data/lib/otto/privacy/config.rb +245 -3
  26. data/lib/otto/privacy/core.rb +93 -14
  27. data/lib/otto/privacy/geo_resolver.rb +228 -128
  28. data/lib/otto/privacy/ip_privacy.rb +24 -0
  29. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  30. data/lib/otto/privacy.rb +3 -1
  31. data/lib/otto/request.rb +8 -1
  32. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  33. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  34. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  35. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  36. data/lib/otto/security/config.rb +23 -1
  37. data/lib/otto/security/core.rb +4 -1
  38. data/lib/otto/security/csp/report_middleware.rb +3 -1
  39. data/lib/otto/security/middleware/ip_privacy_middleware.rb +201 -12
  40. data/lib/otto/security/rate_limiter.rb +7 -1
  41. data/lib/otto/utils.rb +100 -0
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +11 -3
  44. metadata +6 -6
@@ -26,6 +26,13 @@ class Otto
26
26
  # StrategyResult. This type covers the complementary case: a strategy that
27
27
  # owns authorization itself (including permission tiers, which Layer-1 does
28
28
  # not model) and needs to signal a 403 directly.
29
+ #
30
+ # DELIBERATELY has no `terminal` member (unlike AuthFailure): an
31
+ # authorization denial must not halt the strategy chain — a later
32
+ # strategy's success still wins (a different credential may well be
33
+ # permitted). When the chain DOES end in failure, a recorded denial
34
+ # already takes response precedence (403 over 401, even over a terminal
35
+ # halt), so there is nothing a terminal flag here would add.
29
36
  AuthorizationFailure = Data.define(:failure_reason, :auth_method) do
30
37
  # Authorization failures are not an authenticated request state. The
31
38
  # request never reaches the handler, so handler-facing predicates report
@@ -12,9 +12,26 @@ class Otto
12
12
  # This is the main orchestrator that:
13
13
  # - Sets anonymous StrategyResult for unauthenticated routes
14
14
  # - Enforces authentication for protected routes
15
- # - Supports multi-strategy with OR logic (first success wins)
15
+ # - Supports multi-strategy with OR logic (first authenticated success wins)
16
16
  # - Performs Layer 1 (route-level) role authorization
17
17
  #
18
+ # Multi-strategy chain semantics (in precedence order):
19
+ # - AUTHENTICATED success wins immediately; later strategies never run.
20
+ # - TERMINAL failure (AuthFailure with terminal: true — explicit
21
+ # credentials examined and rejected) halts the chain and fails closed,
22
+ # regardless of strategy order. An anonymous success from an
23
+ # anonymous-capable strategy elsewhere in the chain does not rescue the
24
+ # request. See AuthFailure.
25
+ # - ANONYMOUS success (StrategyResult with no user, e.g. from noauth) is
26
+ # held as a fallback while the rest of the chain runs. It wins once the
27
+ # chain completes without an authenticated success or terminal failure,
28
+ # so credential-less requests still fall through to noauth. When more
29
+ # than one strategy produces an anonymous success, the first declared
30
+ # is the fallback; later ones are deliberately ignored.
31
+ # - Plain failures are recorded and the next strategy is consulted
32
+ # (OR logic). If everything fails, an AuthorizationFailure (valid
33
+ # credential, denied) yields 403; otherwise 401.
34
+ #
18
35
  # @example Basic usage
19
36
  # wrapper = RouteAuthWrapper.new(handler, route_def, auth_config)
20
37
  # response = wrapper.call(env)
@@ -53,7 +70,8 @@ class Otto
53
70
  validation_error = validate_strategies(auth_requirements, env)
54
71
  return validation_error if validation_error
55
72
 
56
- # Try each strategy in order (first success wins)
73
+ # Try each strategy in order (first authenticated success wins;
74
+ # anonymous success is a fallback; terminal failure halts the chain)
57
75
  authenticate_and_authorize(env, extra_params, auth_requirements)
58
76
  end
59
77
 
@@ -83,8 +101,15 @@ class Otto
83
101
  end
84
102
 
85
103
  # Main authentication and authorization flow
104
+ #
105
+ # chain tracks two views of the run: :executed is every strategy that
106
+ # ran (including one whose anonymous success is merely held as the
107
+ # fallback), :failed only those that returned a failure result. Failure
108
+ # metadata reports :executed as attempted_strategies so a terminal halt
109
+ # after a deferred anonymous success doesn't under-report what ran.
86
110
  def authenticate_and_authorize(env, extra_params, auth_requirements)
87
- failed_strategies = []
111
+ chain = { failed: [], executed: [] }
112
+ anonymous_fallback = nil
88
113
  total_start_time = Otto::Utils.now_in_μs
89
114
 
90
115
  auth_requirements.each do |requirement|
@@ -96,39 +121,90 @@ class Otto
96
121
  start_time = Otto::Utils.now_in_μs
97
122
  result = strategy.authenticate(env, requirement)
98
123
  duration = Otto::Utils.now_in_μs - start_time
124
+ chain[:executed] << strategy_name
99
125
 
100
126
  # Inject strategy_name into result
101
127
  result = result.with(strategy_name: strategy_name) if result.is_a?(StrategyResult)
102
128
 
103
- # Handle authentication success
104
- if result.is_a?(StrategyResult) && (result.authenticated? || result.anonymous?)
129
+ # Handle authenticated success - wins immediately
130
+ if authenticated_result?(result)
105
131
  return handle_auth_success(env, extra_params, result, strategy_name,
106
- duration, total_start_time, failed_strategies)
132
+ duration, total_start_time, chain)
133
+ end
134
+
135
+ # An anonymous success (e.g. noauth) is held as a fallback rather
136
+ # than winning outright, so a credentialed strategy elsewhere in
137
+ # the chain still gets to examine explicitly presented credentials
138
+ # and reject them terminally, regardless of declaration order.
139
+ # When the chain completes without a terminal failure, the
140
+ # fallback wins (see below), preserving OR fallthrough for
141
+ # credential-less requests.
142
+ if anonymous_result?(result)
143
+ anonymous_fallback ||= { result: result, strategy_name: strategy_name, duration: duration }
144
+ next
107
145
  end
108
146
 
109
147
  # Handle a failure (authentication OR authorization) - record it and
110
148
  # continue to the next strategy (OR logic; a later success still wins).
111
149
  # AuthorizationFailure (valid credential, denied) is tagged so the
112
150
  # final response is 403 instead of 401. See handle_all_strategies_failed.
113
- next unless result.is_a?(AuthFailure) || result.is_a?(AuthorizationFailure)
151
+ # Anything else is a strategy-author bug (wrong return type): the
152
+ # chain skips it as if it failed without a reason, but leaves a
153
+ # trace — a silent drop makes those bugs hard to debug.
154
+ unless result.is_a?(AuthFailure) || result.is_a?(AuthorizationFailure)
155
+ log_unexpected_result(env, strategy_name, result)
156
+ next
157
+ end
114
158
 
115
159
  log_strategy_failure(env, strategy_name, result, duration, auth_requirements, requirement)
116
- failed_strategies << {
117
- strategy: strategy_name,
118
- reason: result.failure_reason,
119
- authorization: result.is_a?(AuthorizationFailure),
120
- }
160
+
161
+ # A terminal failure means explicit credentials were examined and
162
+ # rejected: fail the whole chain closed (401) instead of letting an
163
+ # anonymous-capable strategy accept the request as anonymous.
164
+ if record_failure(chain[:failed], strategy_name, result)
165
+ return handle_all_strategies_failed(env, auth_requirements, chain,
166
+ total_start_time, terminal: true)
167
+ end
168
+ end
169
+
170
+ # Chain completed without authenticated success or terminal failure:
171
+ # a held anonymous success wins (OR fallthrough to noauth et al.)
172
+ if anonymous_fallback
173
+ return handle_auth_success(env, extra_params, anonymous_fallback[:result],
174
+ anonymous_fallback[:strategy_name],
175
+ anonymous_fallback[:duration],
176
+ total_start_time, chain)
121
177
  end
122
178
 
123
179
  # All strategies failed
124
- handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
180
+ handle_all_strategies_failed(env, auth_requirements, chain, total_start_time)
181
+ end
182
+
183
+ def authenticated_result?(result)
184
+ result.is_a?(StrategyResult) && result.authenticated?
185
+ end
186
+
187
+ def anonymous_result?(result)
188
+ result.is_a?(StrategyResult) && result.anonymous?
189
+ end
190
+
191
+ # Append the failure to the running list; returns true when it was terminal
192
+ def record_failure(failed_strategies, strategy_name, result)
193
+ terminal = result.is_a?(AuthFailure) && result.terminal?
194
+ failed_strategies << {
195
+ strategy: strategy_name,
196
+ reason: result.failure_reason,
197
+ authorization: result.is_a?(AuthorizationFailure),
198
+ terminal: terminal,
199
+ }
200
+ terminal
125
201
  end
126
202
 
127
203
  # Handle successful authentication
128
- def handle_auth_success(env, extra_params, result, strategy_name, duration, total_start_time, failed_strategies)
204
+ def handle_auth_success(env, extra_params, result, strategy_name, duration, total_start_time, chain)
129
205
  total_duration = Otto::Utils.now_in_μs - total_start_time
130
206
 
131
- log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
207
+ log_auth_success(env, strategy_name, result, duration, total_duration, chain)
132
208
 
133
209
  # Set environment variables for controllers/logic
134
210
  env['otto.strategy_result'] = result
@@ -148,14 +224,19 @@ class Otto
148
224
  wrapped_handler.call(env, extra_params)
149
225
  end
150
226
 
151
- # Handle case when all authentication strategies fail
152
- def handle_all_strategies_failed(env, auth_requirements, failed_strategies, total_start_time)
227
+ # Handle case when authentication fails for the whole chain — either
228
+ # every strategy failed, or a terminal failure halted the chain early
229
+ # (terminal: true; remaining strategies were deliberately not consulted).
230
+ # chain is the { failed:, executed: } state built by
231
+ # authenticate_and_authorize.
232
+ def handle_all_strategies_failed(env, auth_requirements, chain, total_start_time, terminal: false)
233
+ failed_strategies = chain[:failed]
153
234
  total_duration = Otto::Utils.now_in_μs - total_start_time
154
235
 
155
- log_all_failed(env, failed_strategies, total_duration)
236
+ log_all_failed(env, chain, total_duration, terminal: terminal)
156
237
 
157
238
  # Create anonymous result with failure info
158
- metadata = build_failure_metadata(env, failed_strategies)
239
+ metadata = build_failure_metadata(env, chain, terminal: terminal)
159
240
  failure_strategy_name = determine_failure_strategy_name(auth_requirements, failed_strategies)
160
241
 
161
242
  env['otto.strategy_result'] = StrategyResult.anonymous(
@@ -167,14 +248,18 @@ class Otto
167
248
  # authenticated the subject but denied authorization (wrong role/missing
168
249
  # permission), respond 403 Forbidden rather than 401 — the subject IS
169
250
  # authenticated, they simply lack access. A bare 401 would (incorrectly)
170
- # tell a logged-in client to re-authenticate.
251
+ # tell a logged-in client to re-authenticate. This precedence also holds
252
+ # on a terminal halt: the denial's 403 is the more specific outcome.
171
253
  authz_denial = failed_strategies.find { |f| f[:authorization] }
172
254
  return @response_builder.forbidden(env, authz_denial[:reason]) if authz_denial
173
255
 
256
+ # On a terminal halt the terminal failure is necessarily the last one
257
+ # recorded, so its reason is what gets rendered.
174
258
  last_failure = if failed_strategies.any?
175
259
  AuthFailure.new(
176
260
  failure_reason: failed_strategies.last[:reason],
177
- auth_method: failed_strategies.last[:strategy]
261
+ auth_method: failed_strategies.last[:strategy],
262
+ terminal: failed_strategies.last[:terminal] || false
178
263
  )
179
264
  else
180
265
  AuthFailure.new(
@@ -194,13 +279,21 @@ class Otto
194
279
  end
195
280
 
196
281
  # Build metadata for failed authentication
197
- def build_failure_metadata(env, failed_strategies)
282
+ #
283
+ # attempted_strategies lists every strategy that EXECUTED (including
284
+ # one whose anonymous success was held as the fallback and then
285
+ # overruled by a terminal failure); failure_reasons lists only the
286
+ # reasons of strategies that FAILED, in failure order. The two arrays
287
+ # are therefore not index-aligned.
288
+ def build_failure_metadata(env, chain, terminal: false)
289
+ summary = terminal ? 'Authentication halted by terminal failure' : 'All authentication strategies failed'
198
290
  metadata = {
199
291
  ip: env['otto.client_ip'] || env['REMOTE_ADDR'],
200
- auth_failure: 'All authentication strategies failed',
201
- attempted_strategies: failed_strategies.map { |f| f[:strategy] },
202
- failure_reasons: failed_strategies.map { |f| f[:reason] },
292
+ auth_failure: summary,
293
+ attempted_strategies: chain[:executed],
294
+ failure_reasons: chain[:failed].map { |f| f[:reason] },
203
295
  }
296
+ metadata[:terminal_failure] = true if terminal
204
297
  metadata[:country] = env['otto.privacy.geo_country'] if env['otto.privacy.geo_country']
205
298
  metadata
206
299
  end
@@ -228,7 +321,7 @@ class Otto
228
321
  ))
229
322
  end
230
323
 
231
- def log_auth_success(env, strategy_name, result, duration, total_duration, failed_strategies)
324
+ def log_auth_success(env, strategy_name, result, duration, total_duration, chain)
232
325
  Otto.structured_log(:info, 'Auth strategy result',
233
326
  Otto::LoggingHelpers.request_context(env).merge(
234
327
  strategy: strategy_name,
@@ -236,7 +329,11 @@ class Otto
236
329
  user_id: result.user_id,
237
330
  duration: duration,
238
331
  total_duration: total_duration,
239
- strategies_attempted: failed_strategies.size + 1
332
+ # Every strategy that ran, including the winner (already in
333
+ # chain[:executed] by the time a success is handled) and any
334
+ # held anonymous fallback — not just the failures. Same array
335
+ # shape as the failure log so consumers see one type.
336
+ strategies_attempted: chain[:executed]
240
337
  ))
241
338
  end
242
339
 
@@ -251,12 +348,22 @@ class Otto
251
348
  ))
252
349
  end
253
350
 
254
- def log_all_failed(env, failed_strategies, total_duration)
255
- Otto.structured_log(:warn, 'All auth strategies failed',
351
+ def log_unexpected_result(env, strategy_name, result)
352
+ Otto.structured_log(:warn, 'Auth strategy returned unexpected type',
353
+ Otto::LoggingHelpers.request_context(env).merge(
354
+ strategy: strategy_name,
355
+ result_class: result.class.name
356
+ ))
357
+ end
358
+
359
+ def log_all_failed(env, chain, total_duration, terminal: false)
360
+ message = terminal ? 'Auth chain halted by terminal failure' : 'All auth strategies failed'
361
+ Otto.structured_log(:warn, message,
256
362
  Otto::LoggingHelpers.request_context(env).merge(
257
- strategies_attempted: failed_strategies.map { |f| f[:strategy] },
363
+ strategies_attempted: chain[:executed],
258
364
  total_duration: total_duration,
259
- failure_count: failed_strategies.size
365
+ failure_count: chain[:failed].size,
366
+ terminal: terminal
260
367
  ))
261
368
  end
262
369
  end
@@ -217,6 +217,20 @@ class Otto
217
217
  end
218
218
  end
219
219
 
220
+ # Whether any trusted-proxy IP/CIDR/Regexp matchers are configured.
221
+ #
222
+ # This mirrors {#trusted_proxy?}, which consults the same matcher list, so
223
+ # the two answers stay consistent: a request can only be "via a trusted
224
+ # proxy" when matchers exist. It deliberately EXCLUDES count-based depth
225
+ # mode — depth resolves the client IP but never confers proxy identity
226
+ # trust (the same decoupling {Otto::Request#forwarded_by_trusted_proxy?}
227
+ # applies to X-Forwarded-Proto). Used to gate geo-header trust.
228
+ #
229
+ # @return [Boolean] true when at least one trusted-proxy matcher exists
230
+ def trusted_proxies_configured?
231
+ @trusted_proxy_matchers.any?
232
+ end
233
+
220
234
  # Whether count-based ("trust the last N hops") proxy resolution is active.
221
235
  #
222
236
  # When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
@@ -768,12 +782,20 @@ class Otto
768
782
  # trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
769
783
  # store a nil range and fall back to prefix/regexp matching.
770
784
  #
785
+ # The parsed range is folded through IPAddr#native at registration, to
786
+ # match the fold trusted_proxy? applies to the client address. Without
787
+ # it a mapped-IPv6 proxy entry (::ffff:10.0.0.0/104) could never match,
788
+ # because ip_in_range?'s family check would reject the folded IPv4
789
+ # client — a proxy silently untrusted, which is what gates
790
+ # otto.via_trusted_proxy, secure?, and geo-header trust. #native returns
791
+ # self for entries that are not IPv4-mapped/compatible.
792
+ #
771
793
  # @param entry [String, Regexp, Object] trusted proxy entry being added
772
794
  # @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
773
795
  def register_proxy_matcher(entry)
774
796
  return [entry, nil] unless entry.is_a?(String)
775
797
 
776
- range = parse_ipaddr(entry)
798
+ range = parse_ipaddr(entry)&.native
777
799
  warn_legacy_proxy_entry(entry) unless range
778
800
  [entry, range]
779
801
  end
@@ -181,7 +181,10 @@ class Otto
181
181
  # The middleware is pinned to run OUTERMOST (ahead of CSRF and every other
182
182
  # middleware), so it short-circuits report POSTs before CSRF validation —
183
183
  # browsers can post reports without a CSRF token. This holds regardless of
184
- # the order in which you enable security features.
184
+ # the order in which you enable security features. The one thing that runs
185
+ # ahead of it is IPPrivacyMiddleware, pinned to the outer :entrypoint tier
186
+ # so nothing observes a raw client IP; being a pass-through, it cannot
187
+ # affect the short-circuit.
185
188
  #
186
189
  # SECURITY / DoS: running outermost also means the receiver sits ahead of
187
190
  # rate limiting (rate limiting is inner middleware). This is intentional —
@@ -27,7 +27,9 @@ class Otto
27
27
  # with no CSRF token: the report never reaches the CSRF middleware.
28
28
  # {Otto::Security::Core#enable_csp_reporting!} pins this middleware
29
29
  # OUTERMOST (via the :outermost stack position), so the guarantee holds
30
- # regardless of the order security features are enabled in. The flip side
30
+ # regardless of the order security features are enabled in. Only
31
+ # IPPrivacyMiddleware (pinned to the outer :entrypoint tier) runs ahead
32
+ # of it, and it is a pass-through. The flip side
31
33
  # is that reports also bypass rate limiting — see the DoS note on
32
34
  # {Otto::Security::Core#enable_csp_reporting!}; keep callbacks cheap.
33
35
  # - Enforces a hard {MAX_BODY_BYTES} body cap. Oversized bodies are
@@ -10,8 +10,18 @@ class Otto
10
10
  # Automatically masks IP addresses for privacy by default. Original IPs
11
11
  # are never stored unless privacy is explicitly disabled.
12
12
  #
13
- # This middleware runs FIRST in the stack to ensure all downstream
14
- # middleware and application code receives masked IPs by default.
13
+ # Otto pins this middleware to the OUTERMOST position of the stack (the
14
+ # :entrypoint tier see Otto::Core::MiddlewareStack#add_with_position),
15
+ # so it is the first middleware to touch a request and every other
16
+ # middleware, plus the application, reads masked IPs by default. Before
17
+ # #219 it was registered `position: :first`, which is first-in-array and
18
+ # therefore INNERMOST: only the wrapped app saw masked values while every
19
+ # other middleware still saw the raw peer address.
20
+ #
21
+ # Because it now runs ahead of everything, facts about the ORIGINAL peer
22
+ # that downstream code can no longer derive from the (masked) REMOTE_ADDR
23
+ # are recorded first, as leak-free booleans — never as addresses:
24
+ # env['otto.via_trusted_proxy'] and env['otto.peer_loopback'].
15
25
  #
16
26
  # @example Default behavior (privacy enabled)
17
27
  # # env['REMOTE_ADDR'] is masked to 192.168.1.0
@@ -32,9 +42,6 @@ class Otto
32
42
  @app = app
33
43
  @security_config = security_config
34
44
  @config = security_config&.ip_privacy_config || Otto::Privacy::Config.new
35
-
36
- # Privacy is enabled by default unless explicitly disabled
37
- @privacy_enabled = @config.enabled?
38
45
  end
39
46
 
40
47
  # Process request with IP privacy
@@ -46,7 +53,10 @@ class Otto
46
53
  # canonical client IP for this request, do not re-resolve or re-mask.
47
54
  # This makes stacking two instances (e.g. an app-level mount plus
48
55
  # Otto's built-in router mount) order-safe instead of double-masking.
49
- return @app.call(env) if env.key?('otto.client_ip')
56
+ if env.key?('otto.client_ip')
57
+ ensure_ip_match_present(env)
58
+ return @app.call(env)
59
+ end
50
60
 
51
61
  # Record the connecting peer's trust decision BEFORE any masking, so
52
62
  # secure? can authorize X-Forwarded-Proto canonically even after
@@ -58,7 +68,20 @@ class Otto
58
68
  # downstream OneTimeSecret behavior).
59
69
  env['otto.via_trusted_proxy'] = trusted_proxy?(env['REMOTE_ADDR'])
60
70
 
61
- if @privacy_enabled
71
+ # Same rationale, for loopback: this middleware runs outermost, so a
72
+ # downstream middleware that must authenticate a DIRECT LOCAL CALL
73
+ # (Otto::CaddyTLS::LocalhostGuard) can no longer read the true socket
74
+ # peer from REMOTE_ADDR. Record the verdict here, on the untouched
75
+ # peer, as a boolean — the address itself is never exposed.
76
+ #
77
+ # Deliberately the raw peer, NOT the resolved client IP: resolution
78
+ # honors forwarded headers from trusted proxies, and a co-located
79
+ # reverse proxy on loopback is itself a natural trusted proxy, so
80
+ # resolving first would let `X-Forwarded-For: 127.0.0.1` promote a
81
+ # remote caller to "localhost".
82
+ env['otto.peer_loopback'] = Otto::Utils.loopback_address?(env['REMOTE_ADDR'])
83
+
84
+ if privacy_enabled?
62
85
  apply_privacy(env)
63
86
  else
64
87
  apply_no_privacy(env)
@@ -69,6 +92,56 @@ class Otto
69
92
 
70
93
  private
71
94
 
95
+ # Whether IP privacy is on for this request.
96
+ #
97
+ # Read live from the config rather than cached at construction. Otto
98
+ # builds its middleware stack at the end of Otto.new, but
99
+ # configure_ip_privacy stays legal until the first request (the
100
+ # configuration freeze is deferred — see Otto#initialize). A flag
101
+ # captured in #initialize would therefore ignore a post-construction
102
+ # `configure_ip_privacy(profile: :audit)` and keep masking under a
103
+ # profile the operator explicitly turned off. One predicate call per
104
+ # request buys that correctness.
105
+ #
106
+ # @return [Boolean]
107
+ def privacy_enabled?
108
+ @config.enabled?
109
+ end
110
+
111
+ # Guarantee env['otto.ip_match'] exists on the idempotent-return path.
112
+ #
113
+ # Every path in this middleware that sets otto.client_ip installs the
114
+ # capability first, so a second IPPrivacyMiddleware pass that reaches
115
+ # this guard finds both keys and leaves the precise closure in place.
116
+ # (The no-resolvable-IP path installs the capability but never sets
117
+ # otto.client_ip, so a second pass re-runs apply_privacy and reinstalls
118
+ # an equivalent fail-closed closure — idempotent, since there is nothing
119
+ # to double-mask.) The gap is out-of-contract writes: otto.client_ip is
120
+ # documented as "Set by: IPPrivacyMiddleware" (see Otto::EnvKeys), but
121
+ # an app or test harness that sets it directly trips the idempotency
122
+ # guard and leaves the advertised capability nil — downstream policy
123
+ # code then raises NoMethodError on nil.
124
+ #
125
+ # The repair installs a fail-closed check, NOT one derived from
126
+ # env['otto.client_ip']. That value may already be masked, and matching
127
+ # a masked address against a narrow CIDR produces false ALLOWs (masked
128
+ # 192.168.1.0 falls inside 192.168.1.0/28 when the real client was
129
+ # .200). A universal deny is the safe verdict; the warning below is
130
+ # what makes it diagnosable instead of a silent lockout.
131
+ #
132
+ # @param env [Hash] Rack environment
133
+ def ensure_ip_match_present(env)
134
+ return if env.key?('otto.ip_match')
135
+
136
+ Otto.logger.warn(
137
+ '[IPPrivacyMiddleware] otto.client_ip was set outside this ' \
138
+ 'middleware, so otto.ip_match could not be built from the ' \
139
+ 'unmasked address; installing a fail-closed check (every CIDR ' \
140
+ 'test returns false). Let IPPrivacyMiddleware resolve the client IP.'
141
+ )
142
+ env['otto.ip_match'] = ->(_cidrs) { false }
143
+ end
144
+
72
145
  # Apply privacy settings to environment
73
146
  #
74
147
  # @param env [Hash] Rack environment
@@ -77,7 +150,19 @@ class Otto
77
150
  # canonical resolution step; masking below operates on this value.
78
151
  client_ip = resolve_client_ip(env)
79
152
 
80
- Otto.logger.debug "[IPPrivacyMiddleware] Resolved client IP: #{client_ip}" if Otto.debug
153
+ # Install the verdict-only precision capability while client_ip is
154
+ # still the real address — after this method returns, the raw
155
+ # material is gone (REMOTE_ADDR and forwarded headers rewritten).
156
+ install_ip_match(env, client_ip)
157
+
158
+ # There is deliberately no debug line for the resolution itself. It
159
+ # used to interpolate client_ip, which handed back through the log
160
+ # exactly what these profiles withhold from env — and logs travel
161
+ # further than a process does. Stripped of the address it carried no
162
+ # information worth a line: this method only runs on the masking
163
+ # profiles (:masked / :anonymous), and every branch below already logs
164
+ # its own outcome. To trace resolution here, log a derived value (the
165
+ # masked IP, the family, the trusted-proxy verdict) — never the address.
81
166
 
82
167
  # No resolvable client IP (REMOTE_ADDR absent or blank, and no trusted
83
168
  # forwarded value). There is nothing to mask, and masking would derive
@@ -94,9 +179,18 @@ class Otto
94
179
  # original sensitive data. So still scrub those headers before
95
180
  # bailing — a request with no resolvable IP must not leak an
96
181
  # un-anonymized User-Agent or Referer.
182
+ #
183
+ # Likewise, forwarded headers may still carry raw client addresses
184
+ # (e.g. an X-Forwarded-For / Forwarded value with no usable REMOTE_ADDR
185
+ # to anchor resolution). There is no masked IP to rewrite them to, so
186
+ # DELETE them — leaving them would leak the raw address downstream.
97
187
  if client_ip.to_s.empty?
98
188
  Otto.logger.debug '[IPPrivacyMiddleware] No resolvable client IP; skipping IP masking' if Otto.debug
99
- scrub_sensitive_headers(env, Otto::Privacy::RedactedFingerprint.new(env, @config))
189
+ scrub_sensitive_headers(
190
+ env,
191
+ Otto::Privacy::RedactedFingerprint.new(env, @config, geo_headers_trusted: geo_headers_trusted?(env))
192
+ )
193
+ scrub_forwarded_headers(env)
100
194
  return
101
195
  end
102
196
 
@@ -119,7 +213,13 @@ class Otto
119
213
  # localhost / RFC-1918 addresses (the default dev path) even when a
120
214
  # correlation_secret is configured. Set mask_private_ips to treat
121
215
  # private IPs as public and run them through the full path below.
122
- Otto.logger.debug "[IPPrivacyMiddleware] Private/localhost IP exempted: #{client_ip}" if Otto.debug
216
+ # No address interpolated (see the resolution note at the top of
217
+ # this method). Exempt IPs skip fingerprinting entirely, so there
218
+ # is no derived value to log either — the line records only that
219
+ # the exemption fired. The address is not lost to debugging: this
220
+ # path leaves REMOTE_ADDR unmasked and sets otto.client_ip to the
221
+ # same value, so downstream request logs still carry it.
222
+ Otto.logger.debug '[IPPrivacyMiddleware] Private/localhost IP exempted from masking' if Otto.debug
123
223
  return
124
224
  end
125
225
  end
@@ -128,7 +228,9 @@ class Otto
128
228
  # We temporarily set REMOTE_ADDR to the client IP for fingerprint creation
129
229
  original_remote_addr = env['REMOTE_ADDR']
130
230
  env['REMOTE_ADDR'] = client_ip
131
- fingerprint = Otto::Privacy::RedactedFingerprint.new(env, @config)
231
+ fingerprint = Otto::Privacy::RedactedFingerprint.new(
232
+ env, @config, geo_headers_trusted: geo_headers_trusted?(env)
233
+ )
132
234
  env['REMOTE_ADDR'] = original_remote_addr
133
235
 
134
236
  # Set privacy-safe values in environment
@@ -239,6 +341,51 @@ class Otto
239
341
  Otto::Utils.resolve_client_ip(env, @security_config)
240
342
  end
241
343
 
344
+ # Install env['otto.ip_match']: a verdict-only CIDR membership check
345
+ # over the resolved, UNMASKED client IP.
346
+ #
347
+ # This is the precision axis of the privacy design, decoupled from the
348
+ # observability axis (the privacy profile): policy code downstream —
349
+ # e.g. a per-tenant IP allowlist — can ask "is this client inside
350
+ # these ranges?" at full /32-/128 precision under ANY profile,
351
+ # including full masking. The unmasked address itself never lands in
352
+ # env; only this closure does, and a closure serializes to nothing
353
+ # useful, so env dumps, loggers, and error reporters that walk env
354
+ # cannot leak the IP accidentally.
355
+ #
356
+ # Threat model: the capability is a membership oracle, so deliberate
357
+ # in-process code could reconstruct the address via adaptive queries —
358
+ # but in-process code is already trusted (it could monkeypatch this
359
+ # middleware). The invariant defended is accidental persistence and
360
+ # serialization, and a Proc preserves it where a raw string could not.
361
+ #
362
+ # The closure is installed on every path that resolves an IP (masked,
363
+ # private-exempt, and privacy-disabled). When the request has no
364
+ # resolvable client IP the check returns false — fail-closed for
365
+ # allowlist callers. Invalid CIDR entries raise (configuration error);
366
+ # see Otto::Utils.ip_in_cidrs?.
367
+ #
368
+ # @param env [Hash] Rack environment
369
+ # @param client_ip [String, nil] resolved, unmasked client IP
370
+ def install_ip_match(env, client_ip)
371
+ env['otto.ip_match'] = ->(cidrs) { Otto::Utils.ip_in_cidrs?(client_ip, cidrs) }
372
+ end
373
+
374
+ # Delete forwarded IP headers outright.
375
+ #
376
+ # Used on the no-resolvable-client-IP path, where there is no masked IP
377
+ # to rewrite these to. Leaving them would leak a raw client address (in
378
+ # X-Forwarded-For / X-Real-IP / X-Client-IP / RFC 7239 Forwarded)
379
+ # downstream. Deleting is Rack-SPEC-safe: an absent CGI key is valid.
380
+ #
381
+ # @param env [Hash] Rack environment
382
+ def scrub_forwarded_headers(env)
383
+ env.delete('HTTP_X_FORWARDED_FOR')
384
+ env.delete('HTTP_X_REAL_IP')
385
+ env.delete('HTTP_X_CLIENT_IP')
386
+ env.delete('HTTP_FORWARDED')
387
+ end
388
+
242
389
  # Mask X-Forwarded-For and related proxy headers
243
390
  #
244
391
  # Replaces forwarded IP headers with the masked IP to prevent leakage
@@ -261,6 +408,15 @@ class Otto
261
408
  env['HTTP_X_REAL_IP'] = masked_ip if env['HTTP_X_REAL_IP']
262
409
  env['HTTP_X_CLIENT_IP'] = masked_ip if env['HTTP_X_CLIENT_IP']
263
410
 
411
+ # RFC 7239 Forwarded carries the client IP in a structured `for=`
412
+ # token, and Otto reads it as an authoritative client-IP source in
413
+ # count-based depth mode (trusted_proxy_header 'Forwarded'/'Both').
414
+ # Left as-is it would leak the real IP to downstream code. Redact only
415
+ # the `for=` value(s) so proto=/host=/by= metadata survives.
416
+ if env['HTTP_FORWARDED']
417
+ env['HTTP_FORWARDED'] = Otto::Privacy::IPPrivacy.mask_forwarded_for(env['HTTP_FORWARDED'], masked_ip)
418
+ end
419
+
264
420
  Otto.logger.debug "[IPPrivacyMiddleware] Masked forwarded headers" if Otto.debug
265
421
  end
266
422
 
@@ -274,6 +430,34 @@ class Otto
274
430
  @security_config.trusted_proxy?(ip)
275
431
  end
276
432
 
433
+ # Whether request geo headers may be trusted for this request.
434
+ #
435
+ # Geo headers (CF-IPCountry and friends, plus any app-configured header)
436
+ # are client-spoofable unless the request actually arrived through the
437
+ # CDN/proxy that sets them. So Otto trusts them ONLY when it can verify
438
+ # that origin: a request that arrived via a configured CIDR trusted
439
+ # proxy (identity checked against REMOTE_ADDR).
440
+ #
441
+ # Every other case is untrusted, and geo falls to the local database /
442
+ # custom resolver:
443
+ # - Count-based depth mode: the hop setting the header can't be verified
444
+ # as a geo-CDN (depth proxies are often plain load balancers), and
445
+ # depth configures no CIDR matchers, so trusted_proxies_configured? is
446
+ # false here too.
447
+ # - No trusted-proxy configuration: the header is client-supplied and
448
+ # unverifiable. Deployments behind a real CDN should configure
449
+ # trusted_proxies (or a local database) to get header-based geo.
450
+ #
451
+ # @param env [Hash] Rack environment
452
+ # @return [Boolean]
453
+ def geo_headers_trusted?(env)
454
+ sc = @security_config
455
+ return false unless sc.respond_to?(:trusted_proxies_configured?)
456
+ return false unless sc.trusted_proxies_configured?
457
+
458
+ env['otto.via_trusted_proxy'] == true
459
+ end
460
+
277
461
  # Apply no-privacy settings (privacy explicitly disabled)
278
462
  #
279
463
  # When privacy is disabled, original IP is available for
@@ -284,7 +468,12 @@ class Otto
284
468
  # Resolve the canonical client IP once, even with privacy disabled, so
285
469
  # downstream code can read env['otto.client_ip'] instead of re-deriving
286
470
  # it from REMOTE_ADDR / forwarded headers.
287
- env['otto.client_ip'] = resolve_client_ip(env)
471
+ client_ip = resolve_client_ip(env)
472
+ env['otto.client_ip'] = client_ip
473
+
474
+ # Same precision capability as the privacy-enabled paths, so policy
475
+ # code has one interface regardless of profile.
476
+ install_ip_match(env, client_ip)
288
477
 
289
478
  # Store original values for explicit access when privacy is disabled
290
479
  if env['REMOTE_ADDR']