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.
- 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 +254 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +13 -11
- 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 +180 -0
- data/docs/migrating/v2.3.0.md +55 -22
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/env_keys.rb +58 -12
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/privacy/config.rb +245 -3
- data/lib/otto/privacy/core.rb +104 -14
- 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 +54 -2
- data/lib/otto/privacy.rb +3 -1
- data/lib/otto/request.rb +25 -9
- 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 +61 -1
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +228 -18
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +11 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
if
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
152
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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:
|
|
201
|
-
attempted_strategies:
|
|
202
|
-
failure_reasons:
|
|
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,
|
|
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
|
-
|
|
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
|
|
255
|
-
Otto.structured_log(:warn, '
|
|
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:
|
|
363
|
+
strategies_attempted: chain[:executed],
|
|
258
364
|
total_duration: total_duration,
|
|
259
|
-
failure_count:
|
|
365
|
+
failure_count: chain[:failed].size,
|
|
366
|
+
terminal: terminal
|
|
260
367
|
))
|
|
261
368
|
end
|
|
262
369
|
end
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -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
|
data/lib/otto/security/core.rb
CHANGED
|
@@ -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.
|
|
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
|