otto 2.5.0 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +1 -1
- data/.github/workflows/claude-code-review.yml +1 -1
- data/.github/workflows/claude.yml +1 -1
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +1 -1
- data/.github/workflows/ruby-lint.yml +1 -1
- data/.github/workflows/yardoc.yml +1 -1
- data/.pre-commit-config.yaml +22 -5
- data/CHANGELOG.rst +283 -0
- data/Gemfile +2 -1
- data/Gemfile.lock +14 -12
- data/README.md +13 -3
- data/docs/.gitignore +1 -0
- data/docs/1108-STREAMING_ARCHITECTURE_ANALYSIS.md +1105 -0
- data/docs/1108-STREAMING_SUPPORT_SUMMARY.md +376 -0
- data/docs/geo-country.md +172 -0
- data/docs/reverse-proxy-network-services.md +19 -6
- data/examples/advanced_routes/README.md +49 -0
- data/examples/advanced_routes/config.rb +15 -2
- data/examples/advanced_routes/routes +12 -0
- data/examples/lambda_handlers/README.md +128 -0
- data/examples/lambda_handlers/config.ru +26 -0
- data/examples/lambda_handlers/handlers.rb +75 -0
- data/examples/lambda_handlers/routes +28 -0
- data/examples/simple_geo_resolver.rb +38 -5
- data/lib/otto/caddy_tls/localhost_guard.rb +43 -25
- data/lib/otto/core/configuration.rb +103 -1
- data/lib/otto/core/middleware_stack.rb +72 -25
- data/lib/otto/core/router.rb +67 -10
- data/lib/otto/core/uri_generator.rb +36 -2
- data/lib/otto/env_keys.rb +43 -0
- data/lib/otto/errors.rb +7 -0
- data/lib/otto/logging_helpers.rb +50 -1
- data/lib/otto/mcp/rate_limiting.rb +5 -2
- data/lib/otto/mcp/route_parser.rb +15 -4
- data/lib/otto/privacy/config.rb +281 -3
- data/lib/otto/privacy/core.rb +104 -8
- data/lib/otto/privacy/geo_resolver.rb +228 -128
- data/lib/otto/privacy/ip_privacy.rb +24 -0
- data/lib/otto/privacy/redacted_fingerprint.rb +58 -22
- data/lib/otto/privacy/user_agent_privacy.rb +64 -0
- data/lib/otto/privacy.rb +4 -1
- data/lib/otto/request.rb +35 -1
- data/lib/otto/route.rb +103 -41
- data/lib/otto/route_definition.rb +56 -6
- data/lib/otto/route_handlers/base.rb +4 -0
- data/lib/otto/route_handlers/factory.rb +15 -0
- data/lib/otto/route_handlers/lambda.rb +47 -32
- data/lib/otto/security/authentication/auth_failure.rb +36 -2
- data/lib/otto/security/authentication/auth_strategy.rb +12 -2
- data/lib/otto/security/authentication/authorization_failure.rb +7 -0
- data/lib/otto/security/authentication/route_auth_wrapper.rb +138 -31
- data/lib/otto/security/config.rb +123 -6
- data/lib/otto/security/core.rb +4 -1
- data/lib/otto/security/csp/policy.rb +135 -3
- data/lib/otto/security/csp/report_middleware.rb +3 -1
- data/lib/otto/security/csrf_enforcement_wrapper.rb +68 -0
- data/lib/otto/security/csrf_validation.rb +75 -0
- data/lib/otto/security/middleware/csrf_middleware.rb +15 -71
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +232 -15
- data/lib/otto/security/rate_limiter.rb +7 -1
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +100 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +37 -5
- metadata +13 -6
|
@@ -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
|
@@ -72,7 +72,8 @@ class Otto
|
|
|
72
72
|
:security_headers,
|
|
73
73
|
:csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
|
|
74
74
|
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
|
|
75
|
-
:csp_report_uri, :csp_report_to_url, :csp_violation_callback
|
|
75
|
+
:csp_report_uri, :csp_report_to_url, :csp_violation_callback,
|
|
76
|
+
:csp_directive_overrides
|
|
76
77
|
|
|
77
78
|
# Initialize security configuration with safe defaults
|
|
78
79
|
#
|
|
@@ -100,6 +101,8 @@ class Otto
|
|
|
100
101
|
@csp_report_uri = nil
|
|
101
102
|
@csp_report_to_url = nil
|
|
102
103
|
@csp_violation_callback = nil
|
|
104
|
+
@csp_directive_overrides = {}
|
|
105
|
+
@csp_script_src_override_warned = false
|
|
103
106
|
@rate_limiting_config = { custom_rules: {} }
|
|
104
107
|
@ip_privacy_config = Otto::Privacy::Config.new
|
|
105
108
|
|
|
@@ -214,6 +217,20 @@ class Otto
|
|
|
214
217
|
end
|
|
215
218
|
end
|
|
216
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
|
+
|
|
217
234
|
# Whether count-based ("trust the last N hops") proxy resolution is active.
|
|
218
235
|
#
|
|
219
236
|
# When true, Otto::Utils.resolve_client_ip ignores trusted-proxy CIDRs and
|
|
@@ -367,19 +384,85 @@ class Otto
|
|
|
367
384
|
# Unlike enable_csp!, this doesn't set a static policy but enables the response
|
|
368
385
|
# helper to generate CSP headers with nonces on a per-request basis.
|
|
369
386
|
#
|
|
387
|
+
# Per-directive overrides may be supplied to customize the emitted nonce
|
|
388
|
+
# policy without vendoring the gem. They merge into Otto's base directive
|
|
389
|
+
# sets ({Otto::Security::CSP::Policy.development_directives} /
|
|
390
|
+
# {Otto::Security::CSP::Policy.production_directives}): a matching directive
|
|
391
|
+
# is replaced in place, a new directive is appended, and a nil/false value
|
|
392
|
+
# removes a directive. See {#csp_directive_overrides=} for the accepted
|
|
393
|
+
# shape.
|
|
394
|
+
#
|
|
370
395
|
# @param debug [Boolean] Enable debug logging for CSP headers (default: false)
|
|
396
|
+
# @param directives [Hash] per-directive overrides merged into the base set
|
|
371
397
|
# @return [void]
|
|
372
398
|
# @raise [FrozenError] if configuration is frozen
|
|
373
399
|
#
|
|
374
400
|
# @example
|
|
375
401
|
# config.enable_csp_with_nonce!(debug: true)
|
|
376
|
-
|
|
402
|
+
#
|
|
403
|
+
# @example Restore data: workers (blob: is the default worker-src token)
|
|
404
|
+
# config.enable_csp_with_nonce!(directives: { 'worker-src' => "'self' data: blob:" })
|
|
405
|
+
def enable_csp_with_nonce!(debug: false, directives: {})
|
|
377
406
|
ensure_not_frozen!
|
|
378
407
|
|
|
408
|
+
# Apply overrides before toggling state so a bad +directives+ argument
|
|
409
|
+
# raises without leaving the config half-updated (nonce enabled but
|
|
410
|
+
# overrides not merged).
|
|
411
|
+
merge_csp_directives(directives) unless directives.nil? || directives.empty?
|
|
379
412
|
@csp_nonce_enabled = true
|
|
380
413
|
@debug_csp = debug
|
|
381
414
|
end
|
|
382
415
|
|
|
416
|
+
# Replace the per-directive overrides applied to the nonce CSP policy.
|
|
417
|
+
#
|
|
418
|
+
# Overrides merge into Otto's base directive sets when
|
|
419
|
+
# {#generate_nonce_csp} builds the policy, so a consuming app can adjust
|
|
420
|
+
# ANY directive rather than only `report-uri`/`report-to`. Keys are
|
|
421
|
+
# directive names (String or Symbol, matched case-insensitively); values
|
|
422
|
+
# are the source list as a String (`"'self' blob:"`) or Array
|
|
423
|
+
# (`%w['self' blob:]`), or nil/false to REMOVE the directive.
|
|
424
|
+
#
|
|
425
|
+
# Keys are normalized on store (lowercased, hyphenated) via
|
|
426
|
+
# {Otto::Security::CSP::Policy.normalize_overrides}, so the stored hash
|
|
427
|
+
# never accumulates logically-identical entries under different key styles
|
|
428
|
+
# (`'WORKER-SRC'` and `:worker_src` both read back as `'worker-src'`).
|
|
429
|
+
#
|
|
430
|
+
# @note Overriding `script-src` while nonce mode is enabled disables
|
|
431
|
+
# nonce-based script protection: the per-request nonce cannot be
|
|
432
|
+
# included in a static override, so it is stripped from the emitted
|
|
433
|
+
# header. A warning is logged when a `script-src` override is stored.
|
|
434
|
+
# See {Otto::Security::CSP::Policy.merge_directives}.
|
|
435
|
+
#
|
|
436
|
+
# @param overrides [Hash] directive name => source list / nil
|
|
437
|
+
# @return [void]
|
|
438
|
+
# @raise [FrozenError] if configuration is frozen
|
|
439
|
+
#
|
|
440
|
+
# @example
|
|
441
|
+
# config.csp_directive_overrides = { 'worker-src' => "'self' data: blob:" }
|
|
442
|
+
def csp_directive_overrides=(overrides)
|
|
443
|
+
ensure_not_frozen!
|
|
444
|
+
|
|
445
|
+
normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
|
|
446
|
+
warn_if_script_src_overridden(normalized)
|
|
447
|
+
@csp_directive_overrides = normalized
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Merge additional per-directive overrides into the existing set, leaving
|
|
451
|
+
# untouched any directive not named in +overrides+ (last write wins for a
|
|
452
|
+
# repeated directive). Use this to accumulate overrides incrementally;
|
|
453
|
+
# use {#csp_directive_overrides=} to replace them wholesale.
|
|
454
|
+
#
|
|
455
|
+
# @param overrides [Hash] directive name => source list / nil
|
|
456
|
+
# @return [void]
|
|
457
|
+
# @raise [FrozenError] if configuration is frozen
|
|
458
|
+
def merge_csp_directives(overrides)
|
|
459
|
+
ensure_not_frozen!
|
|
460
|
+
|
|
461
|
+
normalized = Otto::Security::CSP::Policy.normalize_overrides(overrides || {})
|
|
462
|
+
warn_if_script_src_overridden(normalized)
|
|
463
|
+
@csp_directive_overrides = @csp_directive_overrides.merge(normalized)
|
|
464
|
+
end
|
|
465
|
+
|
|
383
466
|
# Disable CSP nonce support
|
|
384
467
|
#
|
|
385
468
|
# @return [void]
|
|
@@ -527,8 +610,10 @@ class Otto
|
|
|
527
610
|
# Generate a CSP policy string with the provided nonce
|
|
528
611
|
#
|
|
529
612
|
# Thin facade over {Otto::Security::CSP::Policy.nonce_policy}; the directive
|
|
530
|
-
# sets and report-uri/report-to assembly live there now.
|
|
531
|
-
#
|
|
613
|
+
# sets and report-uri/report-to assembly live there now. Any configured
|
|
614
|
+
# {#csp_directive_overrides} are merged into the base directive set. Output
|
|
615
|
+
# is byte-identical to Otto's historical policy when no overrides or
|
|
616
|
+
# reporting are configured.
|
|
532
617
|
#
|
|
533
618
|
# @param nonce [String] The nonce value to include in the CSP
|
|
534
619
|
# @param development_mode [Boolean] Whether to use development-friendly directives
|
|
@@ -538,7 +623,8 @@ class Otto
|
|
|
538
623
|
nonce,
|
|
539
624
|
development_mode: development_mode,
|
|
540
625
|
report_uri: @csp_report_uri,
|
|
541
|
-
report_to_url: @csp_report_to_url
|
|
626
|
+
report_to_url: @csp_report_to_url,
|
|
627
|
+
directive_overrides: @csp_directive_overrides
|
|
542
628
|
)
|
|
543
629
|
end
|
|
544
630
|
|
|
@@ -696,12 +782,20 @@ class Otto
|
|
|
696
782
|
# trusted_proxy? never re-parses. Non-IP strings and Regexp/other entries
|
|
697
783
|
# store a nil range and fall back to prefix/regexp matching.
|
|
698
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
|
+
#
|
|
699
793
|
# @param entry [String, Regexp, Object] trusted proxy entry being added
|
|
700
794
|
# @return [Array(Object, IPAddr)] [raw_entry, parsed_range_or_nil]
|
|
701
795
|
def register_proxy_matcher(entry)
|
|
702
796
|
return [entry, nil] unless entry.is_a?(String)
|
|
703
797
|
|
|
704
|
-
range = parse_ipaddr(entry)
|
|
798
|
+
range = parse_ipaddr(entry)&.native
|
|
705
799
|
warn_legacy_proxy_entry(entry) unless range
|
|
706
800
|
[entry, range]
|
|
707
801
|
end
|
|
@@ -819,6 +913,29 @@ class Otto
|
|
|
819
913
|
MSG
|
|
820
914
|
end
|
|
821
915
|
|
|
916
|
+
# Warn once per config instance when a `script-src` override is stored for
|
|
917
|
+
# the nonce CSP policy. The per-request nonce is generated at response time
|
|
918
|
+
# and therefore cannot be present in a static override, so replacing (or
|
|
919
|
+
# removing) `script-src` necessarily strips the nonce from the emitted
|
|
920
|
+
# header — the browser then accepts any inline script the page carries the
|
|
921
|
+
# nonce attribute on, voiding nonce-based protection. Overriding any other
|
|
922
|
+
# directive (e.g. `worker-src`) is unaffected and stays silent.
|
|
923
|
+
#
|
|
924
|
+
# @param normalized [Hash] normalized (lowercased/hyphenated) overrides
|
|
925
|
+
# @return [void]
|
|
926
|
+
def warn_if_script_src_overridden(normalized)
|
|
927
|
+
return unless normalized.key?('script-src')
|
|
928
|
+
return if @csp_script_src_override_warned
|
|
929
|
+
|
|
930
|
+
@csp_script_src_override_warned = true
|
|
931
|
+
Otto.structured_log(:warn, <<~MSG.gsub(/\s+/, ' ').strip, directive: 'script-src')
|
|
932
|
+
[Otto::CSP] A script-src override was configured while nonce mode is in
|
|
933
|
+
use. The per-request nonce cannot be included in a static override, so
|
|
934
|
+
nonce-based script protection is disabled for this policy. Remove the
|
|
935
|
+
script-src override to keep nonce enforcement.
|
|
936
|
+
MSG
|
|
937
|
+
end
|
|
938
|
+
|
|
822
939
|
# Freeze-time backstop: refuse to finalize a production configuration that
|
|
823
940
|
# enables CSRF with a generated (non-configured) secret. Mirrors
|
|
824
941
|
# #validate_trusted_proxy_config! so the failure surfaces at boot, before
|
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 —
|
|
@@ -43,9 +43,14 @@ class Otto
|
|
|
43
43
|
# @param report_to_url [String, nil] absolute URL configured for the
|
|
44
44
|
# modern Reporting API; its presence (not its value) toggles the
|
|
45
45
|
# `report-to <group>` directive (omitted when nil/empty)
|
|
46
|
+
# @param directive_overrides [Hash, nil] per-directive overrides merged
|
|
47
|
+
# into the base set before reporting directives are appended. See
|
|
48
|
+
# {.merge_directives} for the accepted shape (replace a directive's
|
|
49
|
+
# sources, add a new directive, or remove one with a nil/false value).
|
|
46
50
|
# @return [String] complete CSP policy string
|
|
47
|
-
def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil)
|
|
51
|
+
def nonce_policy(nonce, development_mode: false, report_uri: nil, report_to_url: nil, directive_overrides: nil)
|
|
48
52
|
directives = development_mode ? development_directives(nonce) : production_directives(nonce)
|
|
53
|
+
directives = merge_directives(directives, directive_overrides)
|
|
49
54
|
uri_directive = report_uri_directive(report_uri)
|
|
50
55
|
to_directive = report_to_directive(report_to_url)
|
|
51
56
|
directives += ["#{uri_directive};"] if uri_directive
|
|
@@ -88,6 +93,133 @@ class Otto
|
|
|
88
93
|
"report-to #{REPORTING_GROUP}"
|
|
89
94
|
end
|
|
90
95
|
|
|
96
|
+
# Merge per-directive overrides into a base directive set.
|
|
97
|
+
#
|
|
98
|
+
# This is the customization seam the hardcoded directive sets previously
|
|
99
|
+
# lacked: a consuming app can adjust ANY directive (e.g. re-allow
|
|
100
|
+
# `data:` workers via `worker-src 'self' data: blob:`) without
|
|
101
|
+
# vendoring the gem. Order is
|
|
102
|
+
# preserved — an override that matches an existing directive replaces it
|
|
103
|
+
# in place; an override for a directive not in the base set is appended
|
|
104
|
+
# after the base directives (before any reporting directives).
|
|
105
|
+
#
|
|
106
|
+
# Override values:
|
|
107
|
+
# - a String → the directive's source list verbatim, e.g.
|
|
108
|
+
# `'worker-src' => "'self' blob:"` yields `worker-src 'self' blob:;`
|
|
109
|
+
# - an Array → sources joined with a single space, e.g.
|
|
110
|
+
# `%w['self' blob:]`
|
|
111
|
+
# - `nil`/`false` → REMOVE the directive from the emitted policy
|
|
112
|
+
#
|
|
113
|
+
# Directive names are matched case-insensitively (CSP directive names are
|
|
114
|
+
# case-insensitive) and may be given as Strings or Symbols.
|
|
115
|
+
#
|
|
116
|
+
# @note The per-request nonce is embedded in `script-src` (production)
|
|
117
|
+
# and cannot be reproduced in a static override, so replacing (or
|
|
118
|
+
# removing) `script-src` strips the nonce from the emitted header and
|
|
119
|
+
# DEFEATS nonce protection: the browser then accepts any inline script
|
|
120
|
+
# the page carries the nonce attribute on. Overriding `script-src`
|
|
121
|
+
# while nonce mode is enabled therefore disables nonce enforcement;
|
|
122
|
+
# {Otto::Security::Config} logs a warning when such an override is
|
|
123
|
+
# configured. Override other directives freely.
|
|
124
|
+
#
|
|
125
|
+
# @param directives [Array<String>] base directive strings, each `;`-terminated
|
|
126
|
+
# @param overrides [Hash, nil] directive name => source list / nil
|
|
127
|
+
# @return [Array<String>] merged directive strings, each `;`-terminated
|
|
128
|
+
# @raise [ArgumentError] if an override name or source token contains a
|
|
129
|
+
# `;`, newline, or carriage return (see {.build_directive})
|
|
130
|
+
def merge_directives(directives, overrides)
|
|
131
|
+
return directives if overrides.nil? || overrides.empty?
|
|
132
|
+
|
|
133
|
+
normalized = normalize_overrides(overrides)
|
|
134
|
+
consumed = {}
|
|
135
|
+
|
|
136
|
+
merged = directives.filter_map do |directive|
|
|
137
|
+
name = directive_name(directive)
|
|
138
|
+
next directive unless normalized.key?(name)
|
|
139
|
+
|
|
140
|
+
consumed[name] = true
|
|
141
|
+
build_directive(name, normalized[name])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
normalized.each do |name, value|
|
|
145
|
+
next if consumed[name]
|
|
146
|
+
|
|
147
|
+
appended = build_directive(name, value)
|
|
148
|
+
merged << appended if appended
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
merged
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Normalize an overrides hash to lowercased, hyphenated String keys so
|
|
155
|
+
# lookups are case-insensitive and Symbol/String keys are
|
|
156
|
+
# interchangeable. Underscores map to hyphens (no CSP directive contains
|
|
157
|
+
# an underscore) so a Symbol key like `:worker_src` addresses the
|
|
158
|
+
# `worker-src` directive. Blank keys are dropped.
|
|
159
|
+
#
|
|
160
|
+
# @param overrides [Hash]
|
|
161
|
+
# @return [Hash{String=>Object}]
|
|
162
|
+
def normalize_overrides(overrides)
|
|
163
|
+
overrides.each_with_object({}) do |(key, value), acc|
|
|
164
|
+
name = key.to_s.strip.downcase.tr('_', '-')
|
|
165
|
+
acc[name] = value unless name.empty?
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# The directive name (first token) of a `;`-terminated directive string,
|
|
170
|
+
# lowercased for case-insensitive matching.
|
|
171
|
+
#
|
|
172
|
+
# @param directive [String]
|
|
173
|
+
# @return [String]
|
|
174
|
+
def directive_name(directive)
|
|
175
|
+
directive.to_s.strip.delete_suffix(';').split(/\s+/, 2).first.to_s.downcase
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Build a single `;`-terminated directive string from a name and an
|
|
179
|
+
# override value, or nil when the value signals removal (nil/false).
|
|
180
|
+
#
|
|
181
|
+
# The directive name and each source token are validated against CSP's
|
|
182
|
+
# separator characters: a name or token containing `;` (which separates
|
|
183
|
+
# directives), a newline, or a carriage return raises {ArgumentError}
|
|
184
|
+
# rather than silently injecting extra directives — a real footgun when
|
|
185
|
+
# overrides come from env/config files. (The `false` removal sentinel is
|
|
186
|
+
# checked before {Array} so a bare `false` never becomes a `[false]`
|
|
187
|
+
# source list.)
|
|
188
|
+
#
|
|
189
|
+
# @param name [String] directive name
|
|
190
|
+
# @param value [String, Array, nil, false] source list, or nil/false to remove
|
|
191
|
+
# @return [String, nil]
|
|
192
|
+
# @raise [ArgumentError] if the name or a source token contains a `;`,
|
|
193
|
+
# newline, or carriage return
|
|
194
|
+
def build_directive(name, value)
|
|
195
|
+
return nil if value.nil? || value == false
|
|
196
|
+
|
|
197
|
+
reject_injection!('directive name', name)
|
|
198
|
+
sources = Array(value).filter_map do |token|
|
|
199
|
+
str = token.to_s.strip
|
|
200
|
+
next if str.empty?
|
|
201
|
+
|
|
202
|
+
reject_injection!("source for #{name}", str)
|
|
203
|
+
str
|
|
204
|
+
end.join(' ')
|
|
205
|
+
sources.empty? ? "#{name};" : "#{name} #{sources};"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Raise {ArgumentError} when +text+ carries a CSP directive/token
|
|
209
|
+
# separator (`;`, newline, or carriage return) that would let an
|
|
210
|
+
# override break out of its directive and inject another.
|
|
211
|
+
#
|
|
212
|
+
# @param label [String] what is being validated (for the error message)
|
|
213
|
+
# @param text [String]
|
|
214
|
+
# @return [void]
|
|
215
|
+
# @raise [ArgumentError] if +text+ contains `;`, `\n`, or `\r`
|
|
216
|
+
def reject_injection!(label, text)
|
|
217
|
+
return unless text.match?(/[;\r\n]/)
|
|
218
|
+
|
|
219
|
+
raise ArgumentError,
|
|
220
|
+
"invalid CSP #{label}: #{text.inspect} contains a ';', newline, or carriage return"
|
|
221
|
+
end
|
|
222
|
+
|
|
91
223
|
# CSP directives for the development environment.
|
|
92
224
|
#
|
|
93
225
|
# Development mode allows inline scripts/styles and hot reloading
|
|
@@ -108,7 +240,7 @@ class Otto
|
|
|
108
240
|
"form-action 'self';",
|
|
109
241
|
"frame-ancestors 'none';",
|
|
110
242
|
"manifest-src 'self';",
|
|
111
|
-
"worker-src 'self'
|
|
243
|
+
"worker-src 'self' blob:;",
|
|
112
244
|
]
|
|
113
245
|
end
|
|
114
246
|
|
|
@@ -132,7 +264,7 @@ class Otto
|
|
|
132
264
|
"form-action 'self';", # Restrict form submissions to same origin
|
|
133
265
|
"frame-ancestors 'none';", # Prevent site from being embedded in frames
|
|
134
266
|
"manifest-src 'self';", # Allow web app manifests from same origin
|
|
135
|
-
"worker-src 'self'
|
|
267
|
+
"worker-src 'self' blob:;", # Allow Workers from same origin and blob: URLs
|
|
136
268
|
]
|
|
137
269
|
end
|
|
138
270
|
end
|
|
@@ -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
|