otto 2.3.1 → 2.5.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/dependabot.yml +1 -1
- data/.github/workflows/ci.yml +7 -1
- data/.github/workflows/claude-code-review.yml +32 -9
- data/.github/workflows/claude.yml +7 -5
- data/.github/workflows/code-smells.yml +2 -2
- data/.github/workflows/release-gem.yml +12 -2
- data/.github/workflows/ruby-lint.yml +66 -0
- data/.github/workflows/yardoc.yml +117 -0
- data/.yardopts +15 -0
- data/CHANGELOG.rst +129 -0
- data/Gemfile +4 -2
- data/Gemfile.lock +23 -17
- data/README.md +150 -0
- data/docs/.gitignore +1 -0
- data/docs/reverse-proxy-network-services.md +358 -0
- data/examples/caddy_tls_demo/README.md +100 -0
- data/examples/caddy_tls_demo/app.rb +41 -0
- data/examples/caddy_tls_demo/config.ru +31 -0
- data/examples/caddy_tls_demo/routes +9 -0
- data/examples/caddy_tls_demo/standalone.ru +38 -0
- data/lib/otto/caddy_tls/core.rb +74 -0
- data/lib/otto/caddy_tls/localhost_guard.rb +158 -0
- data/lib/otto/caddy_tls/server.rb +149 -0
- data/lib/otto/caddy_tls.rb +7 -0
- data/lib/otto/core/middleware_management.rb +7 -7
- data/lib/otto/core/middleware_stack.rb +40 -5
- data/lib/otto/core/router.rb +4 -8
- data/lib/otto/env_keys.rb +10 -0
- data/lib/otto/request.rb +14 -0
- data/lib/otto/response.rb +80 -43
- data/lib/otto/security/config.rb +234 -51
- data/lib/otto/security/configurator.rb +54 -0
- data/lib/otto/security/core.rb +94 -0
- data/lib/otto/security/csp/emit_middleware.rb +91 -0
- data/lib/otto/security/csp/nonce.rb +78 -0
- data/lib/otto/security/csp/parser.rb +120 -0
- data/lib/otto/security/csp/policy.rb +141 -0
- data/lib/otto/security/csp/report.rb +147 -0
- data/lib/otto/security/csp/report_middleware.rb +120 -0
- data/lib/otto/security/csp/writer.rb +197 -0
- data/lib/otto/security/csp.rb +31 -0
- data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
- data/lib/otto/security.rb +1 -0
- data/lib/otto/utils.rb +36 -0
- data/lib/otto/version.rb +1 -1
- data/lib/otto.rb +26 -3
- metadata +27 -3
data/lib/otto/response.rb
CHANGED
|
@@ -14,12 +14,18 @@ class Otto
|
|
|
14
14
|
# @example Using Otto's response in route handlers
|
|
15
15
|
# def show(req, res)
|
|
16
16
|
# res.send_secure_cookie('session_id', token, 3600)
|
|
17
|
-
# res.
|
|
17
|
+
# res.apply_csp(req.csp_nonce)
|
|
18
18
|
# res.no_cache!
|
|
19
19
|
# end
|
|
20
20
|
#
|
|
21
21
|
# @see Otto#register_response_helpers
|
|
22
22
|
class Response < Rack::Response
|
|
23
|
+
# One-time-per-process guard for the #send_csp_headers deprecation warning.
|
|
24
|
+
@send_csp_headers_deprecation_warned = false
|
|
25
|
+
class << self
|
|
26
|
+
attr_accessor :send_csp_headers_deprecation_warned # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
|
27
|
+
end
|
|
28
|
+
|
|
23
29
|
# Reference to the request object (needed by some response helpers)
|
|
24
30
|
# @return [Otto::Request]
|
|
25
31
|
attr_accessor :request
|
|
@@ -97,56 +103,65 @@ class Otto
|
|
|
97
103
|
headers
|
|
98
104
|
end
|
|
99
105
|
|
|
100
|
-
#
|
|
106
|
+
# Apply a nonce-based Content-Security-Policy to this response.
|
|
107
|
+
#
|
|
108
|
+
# This is THE emission helper: it routes through the single apply core
|
|
109
|
+
# ({Otto::Security::CSP::Writer}), so all the invariants — enabled-only,
|
|
110
|
+
# nonce-present, HTML-only, lowercase key, no duplicate — hold here exactly as
|
|
111
|
+
# they do in the middleware, with no guard logic duplicated. The response's
|
|
112
|
+
# Content-Type must already be set (it decides HTML-only); this helper does
|
|
113
|
+
# NOT set it.
|
|
114
|
+
#
|
|
115
|
+
# `mode: :override` (the default) is the deliberate per-request call: it
|
|
116
|
+
# REPLACES any existing CSP. Pass `mode: :backstop` to defer to an existing
|
|
117
|
+
# policy instead.
|
|
101
118
|
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
119
|
+
# @param nonce [String] the per-request nonce (typically {Otto::Request#csp_nonce})
|
|
120
|
+
# @param mode [Symbol] `:override` or `:backstop` (see {Otto::Security::CSP::Writer::MODES})
|
|
121
|
+
# @param development_mode [Boolean] use development-friendly CSP directives
|
|
122
|
+
# @param security_config [Otto::Security::Config, nil] config to use; resolved
|
|
123
|
+
# from the request env when omitted
|
|
124
|
+
# @return [Otto::Security::CSP::Writer::Result] the outcome (applied?, policy,
|
|
125
|
+
# skip_reason) for uniform observability
|
|
105
126
|
#
|
|
106
|
-
# @
|
|
127
|
+
# @example
|
|
128
|
+
# res['content-type'] = 'text/html; charset=utf-8'
|
|
129
|
+
# res.apply_csp(req.csp_nonce)
|
|
130
|
+
def apply_csp(nonce, mode: :override, development_mode: false, security_config: nil)
|
|
131
|
+
config = security_config || (request&.env && request.env['otto.security_config'])
|
|
132
|
+
Otto::Security::CSP::Writer.apply(
|
|
133
|
+
headers, nonce,
|
|
134
|
+
config: config, mode: mode, development_mode: development_mode
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @deprecated Use {#apply_csp} instead. Retained as a thin shim over the apply
|
|
139
|
+
# core so existing callers keep working while its historical quirks are
|
|
140
|
+
# fixed: a nil/empty nonce no longer emits a broken `script-src 'nonce-'`
|
|
141
|
+
# (it skips), a CSP is no longer emitted for non-HTML responses, and the
|
|
142
|
+
# override notice goes through {Otto.logger} instead of a bare `warn` to
|
|
143
|
+
# stderr. Unlike {#apply_csp}, it still sets the Content-Type for you and
|
|
144
|
+
# emits in `:override` mode.
|
|
145
|
+
#
|
|
146
|
+
# @param content_type [String] Content-Type to set if not already set
|
|
107
147
|
# @param nonce [String] Nonce value to include in CSP directives
|
|
108
|
-
# @param opts [Hash] Options
|
|
148
|
+
# @param opts [Hash] Options
|
|
109
149
|
# @option opts [Otto::Security::Config] :security_config Security config to use
|
|
110
150
|
# @option opts [Boolean] :development_mode Use development-friendly CSP directives
|
|
111
|
-
# @
|
|
112
|
-
# @return [void]
|
|
113
|
-
#
|
|
114
|
-
# @example Basic usage
|
|
115
|
-
# nonce = SecureRandom.base64(16)
|
|
116
|
-
# res.send_csp_headers('text/html; charset=utf-8', nonce)
|
|
117
|
-
#
|
|
118
|
-
# @example With options
|
|
119
|
-
# res.send_csp_headers('text/html; charset=utf-8', nonce, {
|
|
120
|
-
# development_mode: Rails.env.development?,
|
|
121
|
-
# debug: true
|
|
122
|
-
# })
|
|
151
|
+
# @return [Otto::Security::CSP::Writer::Result]
|
|
123
152
|
def send_csp_headers(content_type, nonce, opts = {})
|
|
124
|
-
|
|
125
|
-
headers['content-type'] ||= content_type
|
|
153
|
+
warn_send_csp_headers_deprecated
|
|
126
154
|
|
|
127
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
# Get security configuration
|
|
131
|
-
security_config = opts[:security_config] ||
|
|
132
|
-
(request&.env && request.env['otto.security_config']) ||
|
|
133
|
-
nil
|
|
134
|
-
|
|
135
|
-
# Skip if CSP nonce support is not enabled
|
|
136
|
-
return unless security_config&.csp_nonce_enabled?
|
|
137
|
-
|
|
138
|
-
# Generate CSP policy with nonce
|
|
139
|
-
development_mode = opts[:development_mode] || false
|
|
140
|
-
csp_policy = security_config.generate_nonce_csp(nonce, development_mode: development_mode)
|
|
141
|
-
|
|
142
|
-
# Debug logging if enabled
|
|
143
|
-
debug_enabled = opts[:debug] || security_config.debug_csp?
|
|
144
|
-
if debug_enabled && defined?(Otto.logger)
|
|
145
|
-
Otto.logger.debug "[CSP] #{csp_policy}"
|
|
146
|
-
end
|
|
155
|
+
# Historical behavior the shim keeps (apply_csp does not): default the
|
|
156
|
+
# Content-Type so an HTML response is recognized as HTML.
|
|
157
|
+
headers['content-type'] ||= content_type
|
|
147
158
|
|
|
148
|
-
|
|
149
|
-
|
|
159
|
+
apply_csp(
|
|
160
|
+
nonce,
|
|
161
|
+
mode: :override,
|
|
162
|
+
development_mode: opts[:development_mode] || false,
|
|
163
|
+
security_config: opts[:security_config]
|
|
164
|
+
)
|
|
150
165
|
end
|
|
151
166
|
|
|
152
167
|
# Set cache control headers to prevent caching
|
|
@@ -187,5 +202,27 @@ class Otto
|
|
|
187
202
|
paths.unshift(request.env['SCRIPT_NAME']) if request&.env&.[]('SCRIPT_NAME')
|
|
188
203
|
paths.join('/').gsub('//', '/')
|
|
189
204
|
end
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
# Emit the #send_csp_headers deprecation notice at most once per process
|
|
209
|
+
# (Response is per-request, so the guard lives on the class).
|
|
210
|
+
#
|
|
211
|
+
# The check-then-set on the class flag is deliberately unsynchronized: the
|
|
212
|
+
# race is benign — worst case, two threads racing on the very first call each
|
|
213
|
+
# log the notice once. The flag gates only a log line, never any behavior, so
|
|
214
|
+
# a mutex would add contention on a hot path to save at most a couple of
|
|
215
|
+
# duplicate deprecation lines at startup.
|
|
216
|
+
def warn_send_csp_headers_deprecated
|
|
217
|
+
return if self.class.send_csp_headers_deprecation_warned
|
|
218
|
+
return unless defined?(Otto.logger) && Otto.logger
|
|
219
|
+
|
|
220
|
+
self.class.send_csp_headers_deprecation_warned = true
|
|
221
|
+
Otto.logger.warn(
|
|
222
|
+
'[Otto::Response] #send_csp_headers is deprecated and will be removed in a ' \
|
|
223
|
+
'future release; use #apply_csp(nonce, mode: :override) (set Content-Type first), ' \
|
|
224
|
+
'or mount Otto::Security::CSP::EmitMiddleware via #enable_csp_emission!.'
|
|
225
|
+
)
|
|
226
|
+
end
|
|
190
227
|
end
|
|
191
228
|
end
|
data/lib/otto/security/config.rb
CHANGED
|
@@ -7,6 +7,7 @@ require 'digest'
|
|
|
7
7
|
require 'openssl'
|
|
8
8
|
require 'ipaddr'
|
|
9
9
|
require_relative '../core/freezable'
|
|
10
|
+
require_relative 'csp/policy'
|
|
10
11
|
|
|
11
12
|
class Otto
|
|
12
13
|
module Security
|
|
@@ -44,6 +45,13 @@ class Otto
|
|
|
44
45
|
# depth mode; CIDR-walk is unaffected.
|
|
45
46
|
TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
|
|
46
47
|
|
|
48
|
+
# Endpoint group name shared by the CSP `report-to` directive and the
|
|
49
|
+
# `Reporting-Endpoints` response header (modern Reporting API). Browsers
|
|
50
|
+
# match the directive's group to the header's key, so both must agree.
|
|
51
|
+
# Aliases {Otto::Security::CSP::Policy::REPORTING_GROUP} — the one source
|
|
52
|
+
# the policy builder uses — so the header and the directive cannot drift.
|
|
53
|
+
CSP_REPORTING_GROUP = Otto::Security::CSP::Policy::REPORTING_GROUP
|
|
54
|
+
|
|
47
55
|
# Error raised when CSRF protection is enabled in production without an
|
|
48
56
|
# explicitly configured secret. A randomly-generated per-process secret
|
|
49
57
|
# silently breaks token verification across workers and restarts, so we
|
|
@@ -62,8 +70,9 @@ class Otto
|
|
|
62
70
|
attr_reader :csrf_protection, :csrf_header_key,
|
|
63
71
|
:trusted_proxies, :require_secure_cookies,
|
|
64
72
|
:security_headers,
|
|
65
|
-
:csp_nonce_enabled, :debug_csp, :mcp_auth,
|
|
66
|
-
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header
|
|
73
|
+
:csp_nonce_enabled, :debug_csp, :mcp_auth, :csp_nonce_key,
|
|
74
|
+
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
|
|
75
|
+
:csp_report_uri, :csp_report_to_url, :csp_violation_callback
|
|
67
76
|
|
|
68
77
|
# Initialize security configuration with safe defaults
|
|
69
78
|
#
|
|
@@ -86,6 +95,11 @@ class Otto
|
|
|
86
95
|
@input_validation = true
|
|
87
96
|
@csp_nonce_enabled = false
|
|
88
97
|
@debug_csp = false
|
|
98
|
+
@csp_nonce_key = 'otto.nonce'
|
|
99
|
+
@csp_policy = nil
|
|
100
|
+
@csp_report_uri = nil
|
|
101
|
+
@csp_report_to_url = nil
|
|
102
|
+
@csp_violation_callback = nil
|
|
89
103
|
@rate_limiting_config = { custom_rules: {} }
|
|
90
104
|
@ip_privacy_config = Otto::Privacy::Config.new
|
|
91
105
|
|
|
@@ -343,7 +357,8 @@ class Otto
|
|
|
343
357
|
def enable_csp!(policy = "default-src 'self'")
|
|
344
358
|
ensure_not_frozen!
|
|
345
359
|
|
|
346
|
-
@
|
|
360
|
+
@csp_policy = policy
|
|
361
|
+
@security_headers['content-security-policy'] = build_static_csp(policy)
|
|
347
362
|
end
|
|
348
363
|
|
|
349
364
|
# Enable Content Security Policy (CSP) with nonce support
|
|
@@ -382,6 +397,22 @@ class Otto
|
|
|
382
397
|
@csp_nonce_enabled
|
|
383
398
|
end
|
|
384
399
|
|
|
400
|
+
# Set the Rack env key the framework-owned lazy nonce is memoized under
|
|
401
|
+
# ({Otto::Security::CSP.nonce} / {Otto::Request#csp_nonce}). Defaults to
|
|
402
|
+
# `'otto.nonce'`; override it for an app with an existing convention (e.g.
|
|
403
|
+
# `'onetime.nonce'`) so the accessor adopts that app's env key without a
|
|
404
|
+
# rename. A blank value resets to the default.
|
|
405
|
+
#
|
|
406
|
+
# @param key [String] the env key
|
|
407
|
+
# @return [void]
|
|
408
|
+
# @raise [FrozenError] if configuration is frozen
|
|
409
|
+
def csp_nonce_key=(key)
|
|
410
|
+
ensure_not_frozen!
|
|
411
|
+
|
|
412
|
+
normalized = key.to_s.strip
|
|
413
|
+
@csp_nonce_key = normalized.empty? ? 'otto.nonce' : normalized
|
|
414
|
+
end
|
|
415
|
+
|
|
385
416
|
# Check if CSP debug logging is enabled
|
|
386
417
|
#
|
|
387
418
|
# @return [Boolean] true if CSP debug logging is enabled
|
|
@@ -389,14 +420,126 @@ class Otto
|
|
|
389
420
|
@debug_csp
|
|
390
421
|
end
|
|
391
422
|
|
|
423
|
+
# Configure the path browsers should POST CSP violation reports to.
|
|
424
|
+
#
|
|
425
|
+
# Setting this does two things:
|
|
426
|
+
# 1. A `report-uri <path>` directive is appended to every emitted CSP
|
|
427
|
+
# policy — both the static policy from {#enable_csp!} and the per-request
|
|
428
|
+
# nonce policy from {#generate_nonce_csp} — so browsers know where to
|
|
429
|
+
# send violations.
|
|
430
|
+
# 2. {Otto::Security::CSP::ReportMiddleware} activates for that path (it is
|
|
431
|
+
# inert until a report URI is set).
|
|
432
|
+
#
|
|
433
|
+
# When nil/empty (the default), NO reporting directive is emitted and the
|
|
434
|
+
# policy output is byte-identical to Otto's historical output.
|
|
435
|
+
#
|
|
436
|
+
# For the turnkey setup that also injects the receiving middleware, prefer
|
|
437
|
+
# {Otto::Security::Core#enable_csp_reporting!} on the Otto instance.
|
|
438
|
+
#
|
|
439
|
+
# @param uri [String, nil] path browsers POST reports to (matched against
|
|
440
|
+
# `PATH_INFO`, e.g. `/_/csp-report`), or nil to disable reporting. A
|
|
441
|
+
# value without a leading slash is coerced to an absolute path so it
|
|
442
|
+
# matches the slash-prefixed `PATH_INFO` the middleware compares against.
|
|
443
|
+
# @return [void]
|
|
444
|
+
# @raise [FrozenError] if configuration is frozen
|
|
445
|
+
def csp_report_uri=(uri)
|
|
446
|
+
ensure_not_frozen!
|
|
447
|
+
|
|
448
|
+
@csp_report_uri = normalize_report_path(uri)
|
|
449
|
+
rebuild_static_csp_with_reporting!
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Configure the absolute URL browsers should POST CSP violation reports to
|
|
453
|
+
# via the modern Reporting API (Reporting-Endpoints header + `report-to`
|
|
454
|
+
# directive), complementing the legacy path-based {#csp_report_uri=}.
|
|
455
|
+
#
|
|
456
|
+
# Setting this does two things:
|
|
457
|
+
# 1. A `report-to #{CSP_REPORTING_GROUP}` directive is appended to every
|
|
458
|
+
# emitted CSP policy (static and per-request nonce alike), and a
|
|
459
|
+
# `Reporting-Endpoints` response header maps that group to this URL.
|
|
460
|
+
# 2. Modern browsers (which have deprecated `report-uri`) deliver reports
|
|
461
|
+
# as `application/reports+json` to this endpoint — already parsed by
|
|
462
|
+
# {Otto::Security::CSP::Parser}.
|
|
463
|
+
#
|
|
464
|
+
# The value MUST be an ABSOLUTE URL (Reporting-Endpoints does not accept a
|
|
465
|
+
# bare path). Point it at the same receiver as {#csp_report_uri=}: its path
|
|
466
|
+
# component should equal the report URI so {Otto::Security::CSP::ReportMiddleware}
|
|
467
|
+
# (which matches on PATH_INFO) intercepts modern reports too.
|
|
468
|
+
#
|
|
469
|
+
# When nil/empty (the default), NO `report-to` directive or
|
|
470
|
+
# `Reporting-Endpoints` header is emitted and policy output is
|
|
471
|
+
# byte-identical to Otto's historical output.
|
|
472
|
+
#
|
|
473
|
+
# @param url [String, nil] absolute URL for the Reporting API endpoint, or
|
|
474
|
+
# nil to disable modern reporting.
|
|
475
|
+
# @return [void]
|
|
476
|
+
# @raise [FrozenError] if configuration is frozen
|
|
477
|
+
def csp_report_to_url=(url)
|
|
478
|
+
ensure_not_frozen!
|
|
479
|
+
|
|
480
|
+
@csp_report_to_url = normalize_report_uri(url)
|
|
481
|
+
if @csp_report_to_url
|
|
482
|
+
@security_headers['reporting-endpoints'] = reporting_endpoints_header
|
|
483
|
+
else
|
|
484
|
+
@security_headers.delete('reporting-endpoints')
|
|
485
|
+
end
|
|
486
|
+
rebuild_static_csp_with_reporting!
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# Register the callback invoked once per parsed CSP violation report.
|
|
490
|
+
#
|
|
491
|
+
# The block receives an {Otto::Security::CSP::Report}. Your application
|
|
492
|
+
# decides what to do — log, emit a metric, store, forward, or ignore. Otto
|
|
493
|
+
# adds no storage or database coupling.
|
|
494
|
+
#
|
|
495
|
+
# Registering a second callback REPLACES the first (last registration
|
|
496
|
+
# wins), matching the singular `on_csp_violation` semantics. Calling this
|
|
497
|
+
# with NO block clears (unregisters) any previously-set callback.
|
|
498
|
+
#
|
|
499
|
+
# SECURITY NOTE: report URL fields may carry sensitive path/query data in
|
|
500
|
+
# some applications. Redact them in your callback before logging if needed;
|
|
501
|
+
# Otto passes them through un-redacted (see {Otto::Security::CSP::Report}).
|
|
502
|
+
#
|
|
503
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized report
|
|
504
|
+
# @return [void]
|
|
505
|
+
# @raise [FrozenError] if configuration is frozen
|
|
506
|
+
def on_csp_violation(&block)
|
|
507
|
+
ensure_not_frozen!
|
|
508
|
+
|
|
509
|
+
@csp_violation_callback = block
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
# Invoke the registered violation callback for a report, isolating any
|
|
513
|
+
# error it raises. A misbehaving application callback must never break the
|
|
514
|
+
# report receiver (which always answers 204).
|
|
515
|
+
#
|
|
516
|
+
# @param report [Otto::Security::CSP::Report]
|
|
517
|
+
# @return [void]
|
|
518
|
+
def dispatch_csp_violation(report)
|
|
519
|
+
callback = @csp_violation_callback
|
|
520
|
+
return if callback.nil?
|
|
521
|
+
|
|
522
|
+
callback.call(report)
|
|
523
|
+
rescue StandardError => e
|
|
524
|
+
Otto.logger.error("[Otto::CSP] violation callback raised #{e.class}: #{e.message}")
|
|
525
|
+
end
|
|
526
|
+
|
|
392
527
|
# Generate a CSP policy string with the provided nonce
|
|
393
528
|
#
|
|
529
|
+
# Thin facade over {Otto::Security::CSP::Policy.nonce_policy}; the directive
|
|
530
|
+
# sets and report-uri/report-to assembly live there now. Output is
|
|
531
|
+
# byte-identical to Otto's historical policy.
|
|
532
|
+
#
|
|
394
533
|
# @param nonce [String] The nonce value to include in the CSP
|
|
395
534
|
# @param development_mode [Boolean] Whether to use development-friendly directives
|
|
396
535
|
# @return [String] Complete CSP policy string
|
|
397
536
|
def generate_nonce_csp(nonce, development_mode: false)
|
|
398
|
-
|
|
399
|
-
|
|
537
|
+
Otto::Security::CSP::Policy.nonce_policy(
|
|
538
|
+
nonce,
|
|
539
|
+
development_mode: development_mode,
|
|
540
|
+
report_uri: @csp_report_uri,
|
|
541
|
+
report_to_url: @csp_report_to_url
|
|
542
|
+
)
|
|
400
543
|
end
|
|
401
544
|
|
|
402
545
|
# Enable X-Frame-Options header to prevent clickjacking
|
|
@@ -705,52 +848,92 @@ class Otto
|
|
|
705
848
|
defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
|
|
706
849
|
end
|
|
707
850
|
|
|
708
|
-
#
|
|
709
|
-
#
|
|
710
|
-
#
|
|
711
|
-
#
|
|
712
|
-
#
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
#
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
851
|
+
# Normalize a configured report URI: strip surrounding whitespace and
|
|
852
|
+
# treat a blank string as "not configured" (nil).
|
|
853
|
+
#
|
|
854
|
+
# @param uri [String, nil]
|
|
855
|
+
# @return [String, nil]
|
|
856
|
+
def normalize_report_uri(uri)
|
|
857
|
+
return nil if uri.nil?
|
|
858
|
+
|
|
859
|
+
stripped = uri.to_s.strip
|
|
860
|
+
stripped.empty? ? nil : stripped
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# Normalize a configured report PATH: the local endpoint the receiver
|
|
864
|
+
# matches on `PATH_INFO`. Same strip/blank-to-nil handling as
|
|
865
|
+
# {#normalize_report_uri}, but a bare relative value is coerced to an
|
|
866
|
+
# absolute path — a value like `"csp-report"` would otherwise (a) never
|
|
867
|
+
# equal the slash-prefixed `PATH_INFO` the middleware compares against, and
|
|
868
|
+
# (b) be resolved by browsers relative to the document URL. An absolute URL
|
|
869
|
+
# (contains a scheme) is left untouched.
|
|
870
|
+
#
|
|
871
|
+
# @param uri [String, nil]
|
|
872
|
+
# @return [String, nil]
|
|
873
|
+
def normalize_report_path(uri)
|
|
874
|
+
normalized = normalize_report_uri(uri)
|
|
875
|
+
return nil if normalized.nil?
|
|
876
|
+
return normalized if normalized.start_with?('/') || normalized.include?('://')
|
|
877
|
+
|
|
878
|
+
"/#{normalized}"
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
# Recompute the stored static CSP header so the report-uri / report-to
|
|
882
|
+
# directives track the current settings, independent of the order in which
|
|
883
|
+
# {#enable_csp!} and the report setters were called.
|
|
884
|
+
#
|
|
885
|
+
# The base is normally @csp_policy (set by {#enable_csp!}). When a static
|
|
886
|
+
# CSP was instead injected directly through {#set_security_headers} (so
|
|
887
|
+
# @csp_policy is nil), adopt that header as the base the first time a report
|
|
888
|
+
# directive is configured — so reporting augments it too. Capturing it as
|
|
889
|
+
# the pristine base keeps later rebuilds idempotent. A static header set
|
|
890
|
+
# directly AFTER reporting is configured bypasses this and remains the
|
|
891
|
+
# application's to manage.
|
|
892
|
+
#
|
|
893
|
+
# @return [void]
|
|
894
|
+
def rebuild_static_csp_with_reporting!
|
|
895
|
+
@csp_policy ||= adoptable_static_csp_base
|
|
896
|
+
return if @csp_policy.nil?
|
|
897
|
+
|
|
898
|
+
@security_headers['content-security-policy'] = build_static_csp(@csp_policy)
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
# The current static CSP header when it can serve as a pristine base policy
|
|
902
|
+
# for reporting augmentation: present and not already carrying a report
|
|
903
|
+
# directive (adopting one that does would double-append). Otherwise nil —
|
|
904
|
+
# notably, the nonce path sets no static header, so nothing is adopted.
|
|
905
|
+
#
|
|
906
|
+
# @return [String, nil]
|
|
907
|
+
def adoptable_static_csp_base
|
|
908
|
+
existing = @security_headers['content-security-policy']
|
|
909
|
+
return nil if existing.nil? || existing.empty?
|
|
910
|
+
return nil if existing.include?('report-uri') || existing.include?('report-to')
|
|
911
|
+
|
|
912
|
+
existing
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# The `Reporting-Endpoints` response header value mapping the CSP reporting
|
|
916
|
+
# group to the configured absolute endpoint URL, e.g.
|
|
917
|
+
# `otto-csp="https://example.com/_/csp-report"`.
|
|
918
|
+
#
|
|
919
|
+
# @return [String]
|
|
920
|
+
def reporting_endpoints_header
|
|
921
|
+
%(#{CSP_REPORTING_GROUP}="#{@csp_report_to_url}")
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
# Build the stored static-CSP header value: the base policy plus the
|
|
925
|
+
# optional report-uri and report-to directives. Thin facade over
|
|
926
|
+
# {Otto::Security::CSP::Policy.static_policy}; byte-identical to the bare
|
|
927
|
+
# policy when no reporting is configured.
|
|
928
|
+
#
|
|
929
|
+
# @param policy [String] the base policy passed to {#enable_csp!}
|
|
930
|
+
# @return [String]
|
|
931
|
+
def build_static_csp(policy)
|
|
932
|
+
Otto::Security::CSP::Policy.static_policy(
|
|
933
|
+
policy,
|
|
934
|
+
report_uri: @csp_report_uri,
|
|
935
|
+
report_to_url: @csp_report_to_url
|
|
936
|
+
)
|
|
754
937
|
end
|
|
755
938
|
end
|
|
756
939
|
|
|
@@ -198,6 +198,60 @@ class Otto
|
|
|
198
198
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
199
199
|
end
|
|
200
200
|
|
|
201
|
+
# Mount {Otto::Security::CSP::EmitMiddleware} (passive backstop that emits a
|
|
202
|
+
# nonce CSP for responses lacking one, never clobbering). Enable nonce-CSP
|
|
203
|
+
# ({#enable_csp_with_nonce!}) for it to emit anything; until then it is
|
|
204
|
+
# INERT (a transparent pass-through), not an error, and the two may be
|
|
205
|
+
# enabled in either order. Emit-if-consumed by default — see
|
|
206
|
+
# {Otto::Security::Core#enable_csp_emission!}.
|
|
207
|
+
#
|
|
208
|
+
# @param eager [Boolean] mint-and-emit for every eligible HTML response
|
|
209
|
+
# @param development_mode [Boolean, #call, nil] development-directive toggle;
|
|
210
|
+
# a callable is evaluated per request with the env
|
|
211
|
+
def enable_csp_emission!(eager: false, development_mode: nil)
|
|
212
|
+
return if middleware_enabled?(Otto::Security::CSP::EmitMiddleware)
|
|
213
|
+
|
|
214
|
+
@middleware_stack.add(Otto::Security::CSP::EmitMiddleware, eager: eager, development_mode: development_mode)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Enable turnkey CSP violation reporting: set the report URI (appends a
|
|
218
|
+
# `report-uri` directive to emitted policies), register the callback, and
|
|
219
|
+
# inject {Otto::Security::CSP::ReportMiddleware} pinned OUTERMOST so it
|
|
220
|
+
# intercepts report POSTs ahead of CSRF regardless of enable order.
|
|
221
|
+
#
|
|
222
|
+
# @param report_uri [String] path browsers POST reports to (matched against PATH_INFO)
|
|
223
|
+
# @param endpoint_url [String, nil] absolute URL for the modern Reporting
|
|
224
|
+
# API endpoint (emits `report-to` + `Reporting-Endpoints`); nil emits
|
|
225
|
+
# only the legacy `report-uri`
|
|
226
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized violation report
|
|
227
|
+
def enable_csp_reporting!(report_uri, endpoint_url: nil, &block)
|
|
228
|
+
@security_config.csp_report_uri = report_uri
|
|
229
|
+
@security_config.csp_report_to_url = endpoint_url unless endpoint_url.nil?
|
|
230
|
+
@security_config.on_csp_violation(&block) if block
|
|
231
|
+
|
|
232
|
+
return if middleware_enabled?(Otto::Security::CSP::ReportMiddleware)
|
|
233
|
+
|
|
234
|
+
@middleware_stack.add_with_position(Otto::Security::CSP::ReportMiddleware, position: :outermost)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Configure the CSP violation report path without injecting middleware.
|
|
238
|
+
# Prefer {#enable_csp_reporting!} for the full turnkey setup.
|
|
239
|
+
#
|
|
240
|
+
# @param uri [String, nil] report path (matched against PATH_INFO), or nil to disable
|
|
241
|
+
def csp_report_uri=(uri)
|
|
242
|
+
@security_config.csp_report_uri = uri
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Configure the absolute URL for the modern Reporting API endpoint
|
|
246
|
+
# (`report-to` directive + `Reporting-Endpoints` header) without injecting
|
|
247
|
+
# middleware. Prefer {#enable_csp_reporting!} with `endpoint_url:` for the
|
|
248
|
+
# full turnkey setup.
|
|
249
|
+
#
|
|
250
|
+
# @param url [String, nil] absolute endpoint URL, or nil to disable modern reporting
|
|
251
|
+
def csp_report_to_url=(url)
|
|
252
|
+
@security_config.csp_report_to_url = url
|
|
253
|
+
end
|
|
254
|
+
|
|
201
255
|
# Add a single authentication strategy
|
|
202
256
|
#
|
|
203
257
|
# Part of the Security::Configurator facade for consolidated configuration.
|
data/lib/otto/security/core.rb
CHANGED
|
@@ -135,6 +135,100 @@ class Otto
|
|
|
135
135
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# Mount {Otto::Security::CSP::EmitMiddleware} so nonce-based CSP headers are
|
|
139
|
+
# applied to responses by the framework instead of hand-rolled in each app.
|
|
140
|
+
#
|
|
141
|
+
# It is a passive backstop: it emits a nonce CSP only for responses that
|
|
142
|
+
# would otherwise ship without one, and never clobbers a policy a route
|
|
143
|
+
# already set. Enable nonce-CSP via {#enable_csp_with_nonce!} for it to
|
|
144
|
+
# emit anything — until then the middleware is INERT (a transparent
|
|
145
|
+
# pass-through), NOT an error. The two may be enabled in either order:
|
|
146
|
+
# both read the same security config, so mounting the backstop first and
|
|
147
|
+
# enabling nonce-CSP later works. Enable-order independence is why this
|
|
148
|
+
# does not raise when nonce-CSP is off.
|
|
149
|
+
#
|
|
150
|
+
# By DEFAULT it is emit-if-consumed — it emits only when the request
|
|
151
|
+
# actually consumed a nonce (a view called {Otto::Request#csp_nonce}). This
|
|
152
|
+
# is the only safe blanket default: a nonce-only policy on a page that never
|
|
153
|
+
# stamped the nonce blocks every script.
|
|
154
|
+
#
|
|
155
|
+
# @param eager [Boolean] mint-and-emit for every eligible HTML response
|
|
156
|
+
# rather than only emit-if-consumed (see the middleware's caveats)
|
|
157
|
+
# @param development_mode [Boolean, #call, nil] whether to emit development
|
|
158
|
+
# directives; a callable is evaluated per request with the env
|
|
159
|
+
# @return [void]
|
|
160
|
+
# @example
|
|
161
|
+
# otto.enable_csp_with_nonce!
|
|
162
|
+
# otto.enable_csp_emission!(development_mode: -> (env) { ENV['RACK_ENV'] == 'development' })
|
|
163
|
+
def enable_csp_emission!(eager: false, development_mode: nil)
|
|
164
|
+
ensure_not_frozen!
|
|
165
|
+
return if @middleware.includes?(Otto::Security::CSP::EmitMiddleware)
|
|
166
|
+
|
|
167
|
+
@middleware.add(Otto::Security::CSP::EmitMiddleware, eager: eager, development_mode: development_mode)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Enable turnkey Content Security Policy violation reporting.
|
|
171
|
+
#
|
|
172
|
+
# This is the receiving half of Otto's CSP support. It:
|
|
173
|
+
# 1. Configures the report path (`config.csp_report_uri = report_uri`), so a
|
|
174
|
+
# `report-uri` directive is appended to every emitted CSP policy (static
|
|
175
|
+
# {#enable_csp!} and nonce {#enable_csp_with_nonce!} alike).
|
|
176
|
+
# 2. Registers your violation callback (if a block is given).
|
|
177
|
+
# 3. Injects {Otto::Security::CSP::ReportMiddleware} so browser POSTs to the
|
|
178
|
+
# report path are received, parsed, and dispatched to the callback —
|
|
179
|
+
# always answered with 204 and never touching your routes.
|
|
180
|
+
#
|
|
181
|
+
# The middleware is pinned to run OUTERMOST (ahead of CSRF and every other
|
|
182
|
+
# middleware), so it short-circuits report POSTs before CSRF validation —
|
|
183
|
+
# browsers can post reports without a CSRF token. This holds regardless of
|
|
184
|
+
# the order in which you enable security features.
|
|
185
|
+
#
|
|
186
|
+
# SECURITY / DoS: running outermost also means the receiver sits ahead of
|
|
187
|
+
# rate limiting (rate limiting is inner middleware). This is intentional —
|
|
188
|
+
# a public, unauthenticated report endpoint cannot depend on CSRF, session,
|
|
189
|
+
# or per-client throttling state — but it means a client can POST reports
|
|
190
|
+
# up to the 64 KiB body cap and invoke your callback on each one. Keep the
|
|
191
|
+
# callback cheap and bounded (sample or aggregate; avoid unbounded
|
|
192
|
+
# synchronous I/O), and put request-rate control for this path at the edge
|
|
193
|
+
# (reverse proxy / CDN / WAF) rather than expecting Otto to throttle it.
|
|
194
|
+
#
|
|
195
|
+
# To (re)assign the callback later without touching the wiring, use the
|
|
196
|
+
# config primitive directly: `otto.security_config.on_csp_violation { ... }`.
|
|
197
|
+
#
|
|
198
|
+
# For modern browsers (which have deprecated `report-uri`), also pass
|
|
199
|
+
# `endpoint_url:` — an ABSOLUTE URL whose path is `report_uri`. Otto then
|
|
200
|
+
# emits a `report-to` directive plus a `Reporting-Endpoints` header so those
|
|
201
|
+
# browsers deliver `application/reports+json` to the same receiver.
|
|
202
|
+
#
|
|
203
|
+
# @param report_uri [String] path browsers POST reports to (matched against
|
|
204
|
+
# `PATH_INFO`, e.g. `/_/csp-report`).
|
|
205
|
+
# @param endpoint_url [String, nil] absolute URL for the modern Reporting
|
|
206
|
+
# API endpoint (e.g. `https://example.com/_/csp-report`); nil emits only
|
|
207
|
+
# the legacy `report-uri`.
|
|
208
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized violation report
|
|
209
|
+
# @return [void]
|
|
210
|
+
# @example
|
|
211
|
+
# otto.enable_csp_with_nonce!
|
|
212
|
+
# otto.enable_csp_reporting!('/_/csp-report',
|
|
213
|
+
# endpoint_url: 'https://example.com/_/csp-report') do |report|
|
|
214
|
+
# Otto.logger.warn("CSP violation: #{report.to_h}")
|
|
215
|
+
# end
|
|
216
|
+
def enable_csp_reporting!(report_uri, endpoint_url: nil, &block)
|
|
217
|
+
ensure_not_frozen!
|
|
218
|
+
|
|
219
|
+
@security_config.csp_report_uri = report_uri
|
|
220
|
+
@security_config.csp_report_to_url = endpoint_url unless endpoint_url.nil?
|
|
221
|
+
@security_config.on_csp_violation(&block) if block
|
|
222
|
+
|
|
223
|
+
return if @middleware.includes?(Otto::Security::CSP::ReportMiddleware)
|
|
224
|
+
|
|
225
|
+
# Pin OUTERMOST so it intercepts report POSTs ahead of CSRF regardless of
|
|
226
|
+
# the order security features are enabled in. add_with_position fires the
|
|
227
|
+
# stack's on_change callback, which rebuilds @app (wired in
|
|
228
|
+
# Otto#initialize_core_state) — no explicit build_app! needed.
|
|
229
|
+
@middleware.add_with_position(Otto::Security::CSP::ReportMiddleware, position: :outermost)
|
|
230
|
+
end
|
|
231
|
+
|
|
138
232
|
# Add an authentication strategy with a registered name
|
|
139
233
|
#
|
|
140
234
|
# This is the primary public API for registering authentication strategies.
|