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.
- 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 +218 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +12 -10
- 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/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 +32 -0
- 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 +93 -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 +8 -1
- 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 +23 -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 +201 -12
- 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 +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
|
-
|
|
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
|
@@ -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
|
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
|
|
@@ -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
|
-
#
|
|
14
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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']
|