otto 2.6.0 → 2.8.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 (45) 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 +254 -0
  11. data/Gemfile +2 -1
  12. data/Gemfile.lock +13 -11
  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 +180 -0
  18. data/docs/migrating/v2.3.0.md +55 -22
  19. data/docs/reverse-proxy-network-services.md +19 -6
  20. data/examples/simple_geo_resolver.rb +38 -5
  21. data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
  22. data/lib/otto/core/middleware_stack.rb +72 -25
  23. data/lib/otto/env_keys.rb +58 -12
  24. data/lib/otto/logging_helpers.rb +50 -1
  25. data/lib/otto/mcp/rate_limiting.rb +5 -2
  26. data/lib/otto/privacy/config.rb +245 -3
  27. data/lib/otto/privacy/core.rb +104 -14
  28. data/lib/otto/privacy/geo_resolver.rb +228 -128
  29. data/lib/otto/privacy/ip_privacy.rb +24 -0
  30. data/lib/otto/privacy/redacted_fingerprint.rb +54 -2
  31. data/lib/otto/privacy.rb +3 -1
  32. data/lib/otto/request.rb +25 -9
  33. data/lib/otto/security/authentication/auth_failure.rb +36 -2
  34. data/lib/otto/security/authentication/auth_strategy.rb +12 -2
  35. data/lib/otto/security/authentication/authorization_failure.rb +7 -0
  36. data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
  37. data/lib/otto/security/config.rb +61 -1
  38. data/lib/otto/security/core.rb +4 -1
  39. data/lib/otto/security/csp/report_middleware.rb +3 -1
  40. data/lib/otto/security/middleware/ip_privacy_middleware.rb +228 -18
  41. data/lib/otto/security/rate_limiter.rb +7 -1
  42. data/lib/otto/utils.rb +100 -0
  43. data/lib/otto/version.rb +1 -1
  44. data/lib/otto.rb +11 -3
  45. metadata +5 -2
@@ -6,10 +6,44 @@ class Otto
6
6
  module Security
7
7
  module Authentication
8
8
  # Failure result for authentication failures
9
- AuthFailure = Data.define(:failure_reason, :auth_method) do
9
+ AuthFailure = Data.define(:failure_reason, :auth_method, :terminal) do
10
10
  # AuthFailure represents authentication failure
11
11
  # Returned by strategies when authentication fails
12
12
  # Contains failure reason for error messages
13
+ #
14
+ # TERMINAL FAILURES
15
+ # -----------------
16
+ # By default a failure is non-terminal: RouteAuthWrapper records it and
17
+ # consults the next strategy in the route's chain (OR logic), so a
18
+ # request without credentials can still fall through to an
19
+ # anonymous-capable strategy like noauth.
20
+ #
21
+ # A failure constructed with `terminal: true` means "this request
22
+ # explicitly presented credentials and they were examined and
23
+ # rejected — do not consult further strategies." RouteAuthWrapper
24
+ # halts the chain and renders the 401 with this failure's reason,
25
+ # regardless of where the strategy sits in the chain. This lets mixed
26
+ # credentialed/anonymous chains (e.g. auth=basicauth,noauth) fail
27
+ # closed on invalid credentials instead of silently degrading to
28
+ # anonymous.
29
+ #
30
+ # Only reject terminally when credentials were EXPLICITLY presented
31
+ # (e.g. an Authorization header). Ambient credentials such as session
32
+ # cookies should fail non-terminally so a logged-out browser can still
33
+ # degrade to anonymous on noauth-capable routes.
34
+
35
+ # terminal defaults to false so existing keyword construction
36
+ # (failure_reason:, auth_method:) is unaffected.
37
+ def initialize(failure_reason:, auth_method:, terminal: false)
38
+ super
39
+ end
40
+
41
+ # Check if this failure halts the strategy chain
42
+ #
43
+ # @return [Boolean] True when the chain must fail closed
44
+ def terminal?
45
+ terminal
46
+ end
13
47
 
14
48
  # Check if authenticated - always false for failures
15
49
  #
@@ -36,7 +70,7 @@ class Otto
36
70
  #
37
71
  # @return [String] Debug representation
38
72
  def inspect
39
- "#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}>"
73
+ "#<AuthFailure reason=#{failure_reason.inspect} method=#{auth_method}#{' terminal' if terminal}>"
40
74
  end
41
75
  end
42
76
  end
@@ -42,11 +42,21 @@ class Otto
42
42
  # Use for a missing, invalid, or expired credential. RouteAuthWrapper maps
43
43
  # this to 401 Unauthorized. For a VALID credential that is not permitted,
44
44
  # use #authorization_failure instead (403 Forbidden).
45
- def failure(reason = nil)
45
+ #
46
+ # Pass terminal: true when the request EXPLICITLY presented credentials
47
+ # (e.g. an Authorization header) that were examined and rejected.
48
+ # RouteAuthWrapper then halts the strategy chain and fails closed with
49
+ # 401 instead of consulting further strategies — so an anonymous-capable
50
+ # strategy later (or earlier) in the chain cannot silently accept the
51
+ # request as anonymous. Leave it false for missing credentials and for
52
+ # ambient credentials (session cookies), which should keep today's OR
53
+ # fallthrough. See AuthFailure.
54
+ def failure(reason = nil, terminal: false)
46
55
  Otto.logger.debug "[#{self.class}] Authentication failed: #{reason}" if reason
47
56
  Otto::Security::Authentication::AuthFailure.new(
48
57
  failure_reason: reason || 'Authentication failed',
49
- auth_method: strategy_auth_method
58
+ auth_method: strategy_auth_method,
59
+ terminal: terminal
50
60
  )
51
61
  end
52
62
 
@@ -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
@@ -38,6 +38,23 @@ class Otto
38
38
  hop count, not both.
39
39
  MSG
40
40
 
41
+ # Error raised when an app-configured trusted geo header (ip_privacy
42
+ # geo_header) is combined with count-based depth mode. Geo headers are
43
+ # honored only for peers matching enumerated trusted_proxies CIDRs
44
+ # (geo_headers_trusted? gates on trusted_proxies_configured?) — a hop
45
+ # trusted by count cannot be verified as the geo-setting CDN — so a
46
+ # geo_header configured alongside a depth could never be consulted.
47
+ # Failing loud at config time replaces a silent database/'**' fallback
48
+ # at request time.
49
+ GEO_HEADER_DEPTH_CONFLICT_MESSAGE = <<~MSG.gsub(/\s+/, ' ').strip.freeze
50
+ Cannot configure a trusted geo header (ip_privacy geo_header) together
51
+ with trusted_proxy_depth (count mode): geo headers are only honored
52
+ for peers matching enumerated trusted_proxies CIDRs, so the header
53
+ would be silently ignored. Use filter mode (add_trusted_proxy) for
54
+ header-based geo, or drop geo_header and use database-backed geo
55
+ (geo_db_path or geo_db_reader).
56
+ MSG
57
+
41
58
  # Forwarded-header sources depth mode (#trusted_proxy_depth) can count
42
59
  # hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
43
60
  # Both (Forwarded when present, else X-Forwarded-For). Mirrors
@@ -217,6 +234,32 @@ class Otto
217
234
  end
218
235
  end
219
236
 
237
+ # Whether any trusted-proxy IP/CIDR/Regexp matchers are configured.
238
+ #
239
+ # This mirrors {#trusted_proxy?}, which consults the same matcher list.
240
+ # It deliberately EXCLUDES count-based depth mode: depth grants the peer
241
+ # blanket trust for otto.via_trusted_proxy (#226), but it cannot verify
242
+ # that the hop is a geo-setting CDN, so header-based geo stays gated on
243
+ # enumerated matchers only. Used to gate geo-header trust.
244
+ #
245
+ # @return [Boolean] true when at least one trusted-proxy matcher exists
246
+ def trusted_proxies_configured?
247
+ @trusted_proxy_matchers.any?
248
+ end
249
+
250
+ # Whether ANY proxy-trust mode is configured — CIDR matchers (filter
251
+ # mode) or count-based depth. This is the gate for writing
252
+ # env['otto.via_trusted_proxy'] at all: when neither mode is configured
253
+ # the key is left ABSENT (tri-state contract), so downstream consumers
254
+ # can distinguish "operator configured trust and this peer failed it"
255
+ # (false) from "no proxy trust configured" (absent) and apply their own
256
+ # legacy heuristics only in the latter case.
257
+ #
258
+ # @return [Boolean] true when filter or depth mode is configured
259
+ def proxy_trust_configured?
260
+ trusted_proxies_configured? || trusted_proxy_depth_mode?
261
+ end
262
+
220
263
  # Whether count-based ("trust the last N hops") proxy resolution is active.
221
264
  #
222
265
  # When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
@@ -246,6 +289,9 @@ class Otto
246
289
 
247
290
  validate_trusted_proxy_depth!(depth)
248
291
  raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if depth.to_i >= 1 && @trusted_proxies.any?
292
+ # Depth-then-geo assignment order is caught by configure_ip_privacy;
293
+ # this catches geo-then-depth so both orders fail eagerly.
294
+ raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE if depth.to_i >= 1 && @ip_privacy_config&.geo_header
249
295
 
250
296
  @trusted_proxy_depth = depth
251
297
  end
@@ -749,6 +795,12 @@ class Otto
749
795
  return if @trusted_proxy_depth.nil?
750
796
 
751
797
  raise ArgumentError, PROXY_MODE_CONFLICT_MESSAGE if @trusted_proxy_depth >= 1 && @trusted_proxies.any?
798
+
799
+ # Backstop for the direct path (ip_privacy_config.geo_header=) that
800
+ # bypasses both eager checks; the setters cover the common orders.
801
+ return unless @trusted_proxy_depth >= 1 && @ip_privacy_config&.geo_header
802
+
803
+ raise ArgumentError, GEO_HEADER_DEPTH_CONFLICT_MESSAGE
752
804
  end
753
805
 
754
806
  # Parse a value into an IPAddr, returning nil for invalid / non-IP input.
@@ -768,12 +820,20 @@ class Otto
768
820
  # trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
769
821
  # store a nil range and fall back to prefix/regexp matching.
770
822
  #
823
+ # The parsed range is folded through IPAddr#native at registration, to
824
+ # match the fold trusted_proxy? applies to the client address. Without
825
+ # it a mapped-IPv6 proxy entry (::ffff:10.0.0.0/104) could never match,
826
+ # because ip_in_range?'s family check would reject the folded IPv4
827
+ # client — a proxy silently untrusted, which is what gates
828
+ # otto.via_trusted_proxy, secure?, and geo-header trust. #native returns
829
+ # self for entries that are not IPv4-mapped/compatible.
830
+ #
771
831
  # @param entry [String, Regexp, Object] trusted proxy entry being added
772
832
  # @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
773
833
  def register_proxy_matcher(entry)
774
834
  return [entry, nil] unless entry.is_a?(String)
775
835
 
776
- range = parse_ipaddr(entry)
836
+ range = parse_ipaddr(entry)&.native
777
837
  warn_legacy_proxy_entry(entry) unless range
778
838
  [entry, range]
779
839
  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