otto 2.3.1 → 2.4.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 +59 -0
- data/Gemfile +4 -2
- data/Gemfile.lock +23 -17
- data/README.md +96 -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 +39 -5
- data/lib/otto/core/router.rb +4 -8
- data/lib/otto/security/config.rb +227 -2
- data/lib/otto/security/configurator.rb +38 -0
- data/lib/otto/security/core.rb +62 -0
- data/lib/otto/security/csp/parser.rb +120 -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.rb +19 -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 +23 -3
data/lib/otto/security/config.rb
CHANGED
|
@@ -44,6 +44,11 @@ class Otto
|
|
|
44
44
|
# depth mode; CIDR-walk is unaffected.
|
|
45
45
|
TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
|
|
46
46
|
|
|
47
|
+
# Endpoint group name shared by the CSP `report-to` directive and the
|
|
48
|
+
# `Reporting-Endpoints` response header (modern Reporting API). Browsers
|
|
49
|
+
# match the directive's group to the header's key, so both must agree.
|
|
50
|
+
CSP_REPORTING_GROUP = 'otto-csp'
|
|
51
|
+
|
|
47
52
|
# Error raised when CSRF protection is enabled in production without an
|
|
48
53
|
# explicitly configured secret. A randomly-generated per-process secret
|
|
49
54
|
# silently breaks token verification across workers and restarts, so we
|
|
@@ -63,7 +68,8 @@ class Otto
|
|
|
63
68
|
:trusted_proxies, :require_secure_cookies,
|
|
64
69
|
:security_headers,
|
|
65
70
|
:csp_nonce_enabled, :debug_csp, :mcp_auth,
|
|
66
|
-
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header
|
|
71
|
+
:ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
|
|
72
|
+
:csp_report_uri, :csp_report_to_url, :csp_violation_callback
|
|
67
73
|
|
|
68
74
|
# Initialize security configuration with safe defaults
|
|
69
75
|
#
|
|
@@ -86,6 +92,10 @@ class Otto
|
|
|
86
92
|
@input_validation = true
|
|
87
93
|
@csp_nonce_enabled = false
|
|
88
94
|
@debug_csp = false
|
|
95
|
+
@csp_policy = nil
|
|
96
|
+
@csp_report_uri = nil
|
|
97
|
+
@csp_report_to_url = nil
|
|
98
|
+
@csp_violation_callback = nil
|
|
89
99
|
@rate_limiting_config = { custom_rules: {} }
|
|
90
100
|
@ip_privacy_config = Otto::Privacy::Config.new
|
|
91
101
|
|
|
@@ -343,7 +353,8 @@ class Otto
|
|
|
343
353
|
def enable_csp!(policy = "default-src 'self'")
|
|
344
354
|
ensure_not_frozen!
|
|
345
355
|
|
|
346
|
-
@
|
|
356
|
+
@csp_policy = policy
|
|
357
|
+
@security_headers['content-security-policy'] = build_static_csp(policy)
|
|
347
358
|
end
|
|
348
359
|
|
|
349
360
|
# Enable Content Security Policy (CSP) with nonce support
|
|
@@ -389,6 +400,110 @@ class Otto
|
|
|
389
400
|
@debug_csp
|
|
390
401
|
end
|
|
391
402
|
|
|
403
|
+
# Configure the path browsers should POST CSP violation reports to.
|
|
404
|
+
#
|
|
405
|
+
# Setting this does two things:
|
|
406
|
+
# 1. A `report-uri <path>` directive is appended to every emitted CSP
|
|
407
|
+
# policy — both the static policy from {#enable_csp!} and the per-request
|
|
408
|
+
# nonce policy from {#generate_nonce_csp} — so browsers know where to
|
|
409
|
+
# send violations.
|
|
410
|
+
# 2. {Otto::Security::CSP::ReportMiddleware} activates for that path (it is
|
|
411
|
+
# inert until a report URI is set).
|
|
412
|
+
#
|
|
413
|
+
# When nil/empty (the default), NO reporting directive is emitted and the
|
|
414
|
+
# policy output is byte-identical to Otto's historical output.
|
|
415
|
+
#
|
|
416
|
+
# For the turnkey setup that also injects the receiving middleware, prefer
|
|
417
|
+
# {Otto::Security::Core#enable_csp_reporting!} on the Otto instance.
|
|
418
|
+
#
|
|
419
|
+
# @param uri [String, nil] path browsers POST reports to (matched against
|
|
420
|
+
# `PATH_INFO`, e.g. `/_/csp-report`), or nil to disable reporting. A
|
|
421
|
+
# value without a leading slash is coerced to an absolute path so it
|
|
422
|
+
# matches the slash-prefixed `PATH_INFO` the middleware compares against.
|
|
423
|
+
# @return [void]
|
|
424
|
+
# @raise [FrozenError] if configuration is frozen
|
|
425
|
+
def csp_report_uri=(uri)
|
|
426
|
+
ensure_not_frozen!
|
|
427
|
+
|
|
428
|
+
@csp_report_uri = normalize_report_path(uri)
|
|
429
|
+
rebuild_static_csp_with_reporting!
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Configure the absolute URL browsers should POST CSP violation reports to
|
|
433
|
+
# via the modern Reporting API (Reporting-Endpoints header + `report-to`
|
|
434
|
+
# directive), complementing the legacy path-based {#csp_report_uri=}.
|
|
435
|
+
#
|
|
436
|
+
# Setting this does two things:
|
|
437
|
+
# 1. A `report-to #{CSP_REPORTING_GROUP}` directive is appended to every
|
|
438
|
+
# emitted CSP policy (static and per-request nonce alike), and a
|
|
439
|
+
# `Reporting-Endpoints` response header maps that group to this URL.
|
|
440
|
+
# 2. Modern browsers (which have deprecated `report-uri`) deliver reports
|
|
441
|
+
# as `application/reports+json` to this endpoint — already parsed by
|
|
442
|
+
# {Otto::Security::CSP::Parser}.
|
|
443
|
+
#
|
|
444
|
+
# The value MUST be an ABSOLUTE URL (Reporting-Endpoints does not accept a
|
|
445
|
+
# bare path). Point it at the same receiver as {#csp_report_uri=}: its path
|
|
446
|
+
# component should equal the report URI so {Otto::Security::CSP::ReportMiddleware}
|
|
447
|
+
# (which matches on PATH_INFO) intercepts modern reports too.
|
|
448
|
+
#
|
|
449
|
+
# When nil/empty (the default), NO `report-to` directive or
|
|
450
|
+
# `Reporting-Endpoints` header is emitted and policy output is
|
|
451
|
+
# byte-identical to Otto's historical output.
|
|
452
|
+
#
|
|
453
|
+
# @param url [String, nil] absolute URL for the Reporting API endpoint, or
|
|
454
|
+
# nil to disable modern reporting.
|
|
455
|
+
# @return [void]
|
|
456
|
+
# @raise [FrozenError] if configuration is frozen
|
|
457
|
+
def csp_report_to_url=(url)
|
|
458
|
+
ensure_not_frozen!
|
|
459
|
+
|
|
460
|
+
@csp_report_to_url = normalize_report_uri(url)
|
|
461
|
+
if @csp_report_to_url
|
|
462
|
+
@security_headers['reporting-endpoints'] = reporting_endpoints_header
|
|
463
|
+
else
|
|
464
|
+
@security_headers.delete('reporting-endpoints')
|
|
465
|
+
end
|
|
466
|
+
rebuild_static_csp_with_reporting!
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Register the callback invoked once per parsed CSP violation report.
|
|
470
|
+
#
|
|
471
|
+
# The block receives an {Otto::Security::CSP::Report}. Your application
|
|
472
|
+
# decides what to do — log, emit a metric, store, forward, or ignore. Otto
|
|
473
|
+
# adds no storage or database coupling.
|
|
474
|
+
#
|
|
475
|
+
# Registering a second callback REPLACES the first (last registration
|
|
476
|
+
# wins), matching the singular `on_csp_violation` semantics. Calling this
|
|
477
|
+
# with NO block clears (unregisters) any previously-set callback.
|
|
478
|
+
#
|
|
479
|
+
# SECURITY NOTE: report URL fields may carry sensitive path/query data in
|
|
480
|
+
# some applications. Redact them in your callback before logging if needed;
|
|
481
|
+
# Otto passes them through un-redacted (see {Otto::Security::CSP::Report}).
|
|
482
|
+
#
|
|
483
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized report
|
|
484
|
+
# @return [void]
|
|
485
|
+
# @raise [FrozenError] if configuration is frozen
|
|
486
|
+
def on_csp_violation(&block)
|
|
487
|
+
ensure_not_frozen!
|
|
488
|
+
|
|
489
|
+
@csp_violation_callback = block
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Invoke the registered violation callback for a report, isolating any
|
|
493
|
+
# error it raises. A misbehaving application callback must never break the
|
|
494
|
+
# report receiver (which always answers 204).
|
|
495
|
+
#
|
|
496
|
+
# @param report [Otto::Security::CSP::Report]
|
|
497
|
+
# @return [void]
|
|
498
|
+
def dispatch_csp_violation(report)
|
|
499
|
+
callback = @csp_violation_callback
|
|
500
|
+
return if callback.nil?
|
|
501
|
+
|
|
502
|
+
callback.call(report)
|
|
503
|
+
rescue StandardError => e
|
|
504
|
+
Otto.logger.error("[Otto::CSP] violation callback raised #{e.class}: #{e.message}")
|
|
505
|
+
end
|
|
506
|
+
|
|
392
507
|
# Generate a CSP policy string with the provided nonce
|
|
393
508
|
#
|
|
394
509
|
# @param nonce [String] The nonce value to include in the CSP
|
|
@@ -396,6 +511,10 @@ class Otto
|
|
|
396
511
|
# @return [String] Complete CSP policy string
|
|
397
512
|
def generate_nonce_csp(nonce, development_mode: false)
|
|
398
513
|
directives = development_mode ? development_csp_directives(nonce) : production_csp_directives(nonce)
|
|
514
|
+
report_uri_directive = csp_report_directive
|
|
515
|
+
report_to_directive = csp_report_to_directive
|
|
516
|
+
directives += ["#{report_uri_directive};"] if report_uri_directive
|
|
517
|
+
directives += ["#{report_to_directive};"] if report_to_directive
|
|
399
518
|
directives.join(' ')
|
|
400
519
|
end
|
|
401
520
|
|
|
@@ -705,6 +824,112 @@ class Otto
|
|
|
705
824
|
defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
|
|
706
825
|
end
|
|
707
826
|
|
|
827
|
+
# Normalize a configured report URI: strip surrounding whitespace and
|
|
828
|
+
# treat a blank string as "not configured" (nil).
|
|
829
|
+
#
|
|
830
|
+
# @param uri [String, nil]
|
|
831
|
+
# @return [String, nil]
|
|
832
|
+
def normalize_report_uri(uri)
|
|
833
|
+
return nil if uri.nil?
|
|
834
|
+
|
|
835
|
+
stripped = uri.to_s.strip
|
|
836
|
+
stripped.empty? ? nil : stripped
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Normalize a configured report PATH: the local endpoint the receiver
|
|
840
|
+
# matches on `PATH_INFO`. Same strip/blank-to-nil handling as
|
|
841
|
+
# {#normalize_report_uri}, but a bare relative value is coerced to an
|
|
842
|
+
# absolute path — a value like `"csp-report"` would otherwise (a) never
|
|
843
|
+
# equal the slash-prefixed `PATH_INFO` the middleware compares against, and
|
|
844
|
+
# (b) be resolved by browsers relative to the document URL. An absolute URL
|
|
845
|
+
# (contains a scheme) is left untouched.
|
|
846
|
+
#
|
|
847
|
+
# @param uri [String, nil]
|
|
848
|
+
# @return [String, nil]
|
|
849
|
+
def normalize_report_path(uri)
|
|
850
|
+
normalized = normalize_report_uri(uri)
|
|
851
|
+
return nil if normalized.nil?
|
|
852
|
+
return normalized if normalized.start_with?('/') || normalized.include?('://')
|
|
853
|
+
|
|
854
|
+
"/#{normalized}"
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Recompute the stored static CSP header so the report-uri / report-to
|
|
858
|
+
# directives track the current settings, independent of the order in which
|
|
859
|
+
# {#enable_csp!} and the report setters were called.
|
|
860
|
+
#
|
|
861
|
+
# The base is normally @csp_policy (set by {#enable_csp!}). When a static
|
|
862
|
+
# CSP was instead injected directly through {#set_security_headers} (so
|
|
863
|
+
# @csp_policy is nil), adopt that header as the base the first time a report
|
|
864
|
+
# directive is configured — so reporting augments it too. Capturing it as
|
|
865
|
+
# the pristine base keeps later rebuilds idempotent. A static header set
|
|
866
|
+
# directly AFTER reporting is configured bypasses this and remains the
|
|
867
|
+
# application's to manage.
|
|
868
|
+
#
|
|
869
|
+
# @return [void]
|
|
870
|
+
def rebuild_static_csp_with_reporting!
|
|
871
|
+
@csp_policy ||= adoptable_static_csp_base
|
|
872
|
+
return if @csp_policy.nil?
|
|
873
|
+
|
|
874
|
+
@security_headers['content-security-policy'] = build_static_csp(@csp_policy)
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# The current static CSP header when it can serve as a pristine base policy
|
|
878
|
+
# for reporting augmentation: present and not already carrying a report
|
|
879
|
+
# directive (adopting one that does would double-append). Otherwise nil —
|
|
880
|
+
# notably, the nonce path sets no static header, so nothing is adopted.
|
|
881
|
+
#
|
|
882
|
+
# @return [String, nil]
|
|
883
|
+
def adoptable_static_csp_base
|
|
884
|
+
existing = @security_headers['content-security-policy']
|
|
885
|
+
return nil if existing.nil? || existing.empty?
|
|
886
|
+
return nil if existing.include?('report-uri') || existing.include?('report-to')
|
|
887
|
+
|
|
888
|
+
existing
|
|
889
|
+
end
|
|
890
|
+
|
|
891
|
+
# The `report-uri` directive to append to emitted policies, or nil when no
|
|
892
|
+
# report URI is configured. No trailing semicolon (callers add their own
|
|
893
|
+
# separator to match each policy style).
|
|
894
|
+
#
|
|
895
|
+
# @return [String, nil]
|
|
896
|
+
def csp_report_directive
|
|
897
|
+
return nil if @csp_report_uri.nil? || @csp_report_uri.empty?
|
|
898
|
+
|
|
899
|
+
"report-uri #{@csp_report_uri}"
|
|
900
|
+
end
|
|
901
|
+
|
|
902
|
+
# The `report-to` directive (modern Reporting API) to append to emitted
|
|
903
|
+
# policies, or nil when no reporting endpoint URL is configured. Its group
|
|
904
|
+
# name matches the Reporting-Endpoints header. No trailing semicolon.
|
|
905
|
+
#
|
|
906
|
+
# @return [String, nil]
|
|
907
|
+
def csp_report_to_directive
|
|
908
|
+
return nil if @csp_report_to_url.nil? || @csp_report_to_url.empty?
|
|
909
|
+
|
|
910
|
+
"report-to #{CSP_REPORTING_GROUP}"
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# The `Reporting-Endpoints` response header value mapping the CSP reporting
|
|
914
|
+
# group to the configured absolute endpoint URL, e.g.
|
|
915
|
+
# `otto-csp="https://example.com/_/csp-report"`.
|
|
916
|
+
#
|
|
917
|
+
# @return [String]
|
|
918
|
+
def reporting_endpoints_header
|
|
919
|
+
%(#{CSP_REPORTING_GROUP}="#{@csp_report_to_url}")
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
# Build the stored static-CSP header value: the base policy plus the
|
|
923
|
+
# optional report-uri and report-to directives. Byte-identical to the bare
|
|
924
|
+
# policy when no reporting is configured, so existing static-CSP output is
|
|
925
|
+
# unchanged.
|
|
926
|
+
#
|
|
927
|
+
# @param policy [String] the base policy passed to {#enable_csp!}
|
|
928
|
+
# @return [String]
|
|
929
|
+
def build_static_csp(policy)
|
|
930
|
+
[policy, csp_report_directive, csp_report_to_directive].compact.join('; ')
|
|
931
|
+
end
|
|
932
|
+
|
|
708
933
|
# Generate CSP directives for development environment
|
|
709
934
|
#
|
|
710
935
|
# Development mode allows inline scripts/styles and hot reloading connections
|
|
@@ -198,6 +198,44 @@ class Otto
|
|
|
198
198
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
199
199
|
end
|
|
200
200
|
|
|
201
|
+
# Enable turnkey CSP violation reporting: set the report URI (appends a
|
|
202
|
+
# `report-uri` directive to emitted policies), register the callback, and
|
|
203
|
+
# inject {Otto::Security::CSP::ReportMiddleware} pinned OUTERMOST so it
|
|
204
|
+
# intercepts report POSTs ahead of CSRF regardless of enable order.
|
|
205
|
+
#
|
|
206
|
+
# @param report_uri [String] path browsers POST reports to (matched against PATH_INFO)
|
|
207
|
+
# @param endpoint_url [String, nil] absolute URL for the modern Reporting
|
|
208
|
+
# API endpoint (emits `report-to` + `Reporting-Endpoints`); nil emits
|
|
209
|
+
# only the legacy `report-uri`
|
|
210
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized violation report
|
|
211
|
+
def enable_csp_reporting!(report_uri, endpoint_url: nil, &block)
|
|
212
|
+
@security_config.csp_report_uri = report_uri
|
|
213
|
+
@security_config.csp_report_to_url = endpoint_url unless endpoint_url.nil?
|
|
214
|
+
@security_config.on_csp_violation(&block) if block
|
|
215
|
+
|
|
216
|
+
return if middleware_enabled?(Otto::Security::CSP::ReportMiddleware)
|
|
217
|
+
|
|
218
|
+
@middleware_stack.add_with_position(Otto::Security::CSP::ReportMiddleware, position: :outermost)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Configure the CSP violation report path without injecting middleware.
|
|
222
|
+
# Prefer {#enable_csp_reporting!} for the full turnkey setup.
|
|
223
|
+
#
|
|
224
|
+
# @param uri [String, nil] report path (matched against PATH_INFO), or nil to disable
|
|
225
|
+
def csp_report_uri=(uri)
|
|
226
|
+
@security_config.csp_report_uri = uri
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Configure the absolute URL for the modern Reporting API endpoint
|
|
230
|
+
# (`report-to` directive + `Reporting-Endpoints` header) without injecting
|
|
231
|
+
# middleware. Prefer {#enable_csp_reporting!} with `endpoint_url:` for the
|
|
232
|
+
# full turnkey setup.
|
|
233
|
+
#
|
|
234
|
+
# @param url [String, nil] absolute endpoint URL, or nil to disable modern reporting
|
|
235
|
+
def csp_report_to_url=(url)
|
|
236
|
+
@security_config.csp_report_to_url = url
|
|
237
|
+
end
|
|
238
|
+
|
|
201
239
|
# Add a single authentication strategy
|
|
202
240
|
#
|
|
203
241
|
# Part of the Security::Configurator facade for consolidated configuration.
|
data/lib/otto/security/core.rb
CHANGED
|
@@ -135,6 +135,68 @@ class Otto
|
|
|
135
135
|
@security_config.enable_csp_with_nonce!(debug: debug)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
+
# Enable turnkey Content Security Policy violation reporting.
|
|
139
|
+
#
|
|
140
|
+
# This is the receiving half of Otto's CSP support. It:
|
|
141
|
+
# 1. Configures the report path (`config.csp_report_uri = report_uri`), so a
|
|
142
|
+
# `report-uri` directive is appended to every emitted CSP policy (static
|
|
143
|
+
# {#enable_csp!} and nonce {#enable_csp_with_nonce!} alike).
|
|
144
|
+
# 2. Registers your violation callback (if a block is given).
|
|
145
|
+
# 3. Injects {Otto::Security::CSP::ReportMiddleware} so browser POSTs to the
|
|
146
|
+
# report path are received, parsed, and dispatched to the callback —
|
|
147
|
+
# always answered with 204 and never touching your routes.
|
|
148
|
+
#
|
|
149
|
+
# The middleware is pinned to run OUTERMOST (ahead of CSRF and every other
|
|
150
|
+
# middleware), so it short-circuits report POSTs before CSRF validation —
|
|
151
|
+
# browsers can post reports without a CSRF token. This holds regardless of
|
|
152
|
+
# the order in which you enable security features.
|
|
153
|
+
#
|
|
154
|
+
# SECURITY / DoS: running outermost also means the receiver sits ahead of
|
|
155
|
+
# rate limiting (rate limiting is inner middleware). This is intentional —
|
|
156
|
+
# a public, unauthenticated report endpoint cannot depend on CSRF, session,
|
|
157
|
+
# or per-client throttling state — but it means a client can POST reports
|
|
158
|
+
# up to the 64 KiB body cap and invoke your callback on each one. Keep the
|
|
159
|
+
# callback cheap and bounded (sample or aggregate; avoid unbounded
|
|
160
|
+
# synchronous I/O), and put request-rate control for this path at the edge
|
|
161
|
+
# (reverse proxy / CDN / WAF) rather than expecting Otto to throttle it.
|
|
162
|
+
#
|
|
163
|
+
# To (re)assign the callback later without touching the wiring, use the
|
|
164
|
+
# config primitive directly: `otto.security_config.on_csp_violation { ... }`.
|
|
165
|
+
#
|
|
166
|
+
# For modern browsers (which have deprecated `report-uri`), also pass
|
|
167
|
+
# `endpoint_url:` — an ABSOLUTE URL whose path is `report_uri`. Otto then
|
|
168
|
+
# emits a `report-to` directive plus a `Reporting-Endpoints` header so those
|
|
169
|
+
# browsers deliver `application/reports+json` to the same receiver.
|
|
170
|
+
#
|
|
171
|
+
# @param report_uri [String] path browsers POST reports to (matched against
|
|
172
|
+
# `PATH_INFO`, e.g. `/_/csp-report`).
|
|
173
|
+
# @param endpoint_url [String, nil] absolute URL for the modern Reporting
|
|
174
|
+
# API endpoint (e.g. `https://example.com/_/csp-report`); nil emits only
|
|
175
|
+
# the legacy `report-uri`.
|
|
176
|
+
# @yieldparam report [Otto::Security::CSP::Report] a normalized violation report
|
|
177
|
+
# @return [void]
|
|
178
|
+
# @example
|
|
179
|
+
# otto.enable_csp_with_nonce!
|
|
180
|
+
# otto.enable_csp_reporting!('/_/csp-report',
|
|
181
|
+
# endpoint_url: 'https://example.com/_/csp-report') do |report|
|
|
182
|
+
# Otto.logger.warn("CSP violation: #{report.to_h}")
|
|
183
|
+
# end
|
|
184
|
+
def enable_csp_reporting!(report_uri, endpoint_url: nil, &block)
|
|
185
|
+
ensure_not_frozen!
|
|
186
|
+
|
|
187
|
+
@security_config.csp_report_uri = report_uri
|
|
188
|
+
@security_config.csp_report_to_url = endpoint_url unless endpoint_url.nil?
|
|
189
|
+
@security_config.on_csp_violation(&block) if block
|
|
190
|
+
|
|
191
|
+
return if @middleware.includes?(Otto::Security::CSP::ReportMiddleware)
|
|
192
|
+
|
|
193
|
+
# Pin OUTERMOST so it intercepts report POSTs ahead of CSRF regardless of
|
|
194
|
+
# the order security features are enabled in. add_with_position fires the
|
|
195
|
+
# stack's on_change callback, which rebuilds @app (wired in
|
|
196
|
+
# Otto#initialize_core_state) — no explicit build_app! needed.
|
|
197
|
+
@middleware.add_with_position(Otto::Security::CSP::ReportMiddleware, position: :outermost)
|
|
198
|
+
end
|
|
199
|
+
|
|
138
200
|
# Add an authentication strategy with a registered name
|
|
139
201
|
#
|
|
140
202
|
# This is the primary public API for registering authentication strategies.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# lib/otto/security/csp/parser.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
require_relative 'report'
|
|
8
|
+
|
|
9
|
+
class Otto
|
|
10
|
+
module Security
|
|
11
|
+
module CSP
|
|
12
|
+
# Parses inbound Content-Security-Policy violation report bodies into a
|
|
13
|
+
# list of normalized {Otto::Security::CSP::Report} objects.
|
|
14
|
+
#
|
|
15
|
+
# Handles BOTH standardized wire formats:
|
|
16
|
+
#
|
|
17
|
+
# - Legacy `application/csp-report` — a single JSON object
|
|
18
|
+
# `{"csp-report": { ... }}`.
|
|
19
|
+
# - Reporting API `application/reports+json` — a JSON ARRAY of
|
|
20
|
+
# `{"type": "csp-violation", "body": { ... }}` entries (a single
|
|
21
|
+
# un-wrapped object is tolerated too).
|
|
22
|
+
#
|
|
23
|
+
# The parser keys off the JSON SHAPE rather than trusting the declared
|
|
24
|
+
# `Content-Type`, because browsers and intermediaries are inconsistent
|
|
25
|
+
# about the header. The `content_type` argument is accepted for future use
|
|
26
|
+
# and symmetry with the middleware but is not currently required to
|
|
27
|
+
# disambiguate.
|
|
28
|
+
#
|
|
29
|
+
# It is intentionally TOTAL: malformed JSON, an unexpected top-level type,
|
|
30
|
+
# or entries that are not CSP violations yield an empty array rather than
|
|
31
|
+
# raising. A violation-report receiver must never fail on hostile input.
|
|
32
|
+
module Parser
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# Parse a raw report body into normalized reports.
|
|
36
|
+
#
|
|
37
|
+
# @param body [String, nil] the raw request body (JSON).
|
|
38
|
+
# @param content_type [String, nil] the request Content-Type (hint only).
|
|
39
|
+
# @return [Array<Otto::Security::CSP::Report>] zero or more normalized
|
|
40
|
+
# reports. Empty when the body is nil/blank/malformed or contains no
|
|
41
|
+
# recognizable CSP violations.
|
|
42
|
+
def parse(body, content_type = nil)
|
|
43
|
+
return [] if body.nil? || body.empty?
|
|
44
|
+
|
|
45
|
+
data = safe_json_parse(body)
|
|
46
|
+
return [] if data.nil?
|
|
47
|
+
|
|
48
|
+
extract_raw_reports(data, content_type).filter_map do |raw|
|
|
49
|
+
Report.from_raw(raw)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse JSON, swallowing the errors a hostile/garbled body can throw.
|
|
54
|
+
#
|
|
55
|
+
# @param body [String]
|
|
56
|
+
# @return [Object, nil] the parsed structure, or nil on any parse error.
|
|
57
|
+
def safe_json_parse(body)
|
|
58
|
+
JSON.parse(body)
|
|
59
|
+
rescue JSON::ParserError, EncodingError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Pull the per-violation field hashes out of either wire format.
|
|
64
|
+
#
|
|
65
|
+
# @param data [Object] the parsed JSON structure.
|
|
66
|
+
# @param _content_type [String, nil] unused (shape drives extraction).
|
|
67
|
+
# @return [Array<Hash>] raw, un-normalized per-violation field hashes.
|
|
68
|
+
def extract_raw_reports(data, _content_type = nil)
|
|
69
|
+
case data
|
|
70
|
+
when Array
|
|
71
|
+
extract_from_reporting_api(data)
|
|
72
|
+
when Hash
|
|
73
|
+
extract_from_object(data)
|
|
74
|
+
else
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reporting API batch: an array of report envelopes. Keep entries that
|
|
80
|
+
# are (or are untyped but shaped like) CSP violations and carry a body.
|
|
81
|
+
#
|
|
82
|
+
# @param entries [Array]
|
|
83
|
+
# @return [Array<Hash>]
|
|
84
|
+
def extract_from_reporting_api(entries)
|
|
85
|
+
entries.filter_map do |entry|
|
|
86
|
+
next unless entry.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
body = entry['body']
|
|
89
|
+
next unless body.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
type = entry['type']
|
|
92
|
+
# Accept entries explicitly typed csp-violation, or untyped bodies.
|
|
93
|
+
# Skip other report types (deprecation, intervention, ...).
|
|
94
|
+
next unless type.nil? || type == 'csp-violation'
|
|
95
|
+
|
|
96
|
+
body
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A single top-level object in either the legacy `{"csp-report": {...}}`
|
|
101
|
+
# envelope or a lone Reporting API `{"type":..., "body": {...}}` object.
|
|
102
|
+
#
|
|
103
|
+
# @param data [Hash]
|
|
104
|
+
# @return [Array<Hash>]
|
|
105
|
+
def extract_from_object(data)
|
|
106
|
+
if data['csp-report'].is_a?(Hash)
|
|
107
|
+
[data['csp-report']]
|
|
108
|
+
elsif data['body'].is_a?(Hash) && (data['type'].nil? || data['type'] == 'csp-violation')
|
|
109
|
+
# Mirror extract_from_reporting_api: accept a lone csp-violation (or
|
|
110
|
+
# untyped) envelope, but skip other single-object report types
|
|
111
|
+
# (deprecation, intervention, ...).
|
|
112
|
+
[data['body']]
|
|
113
|
+
else
|
|
114
|
+
[]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# lib/otto/security/csp/report.rb
|
|
2
|
+
#
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
class Otto
|
|
6
|
+
module Security
|
|
7
|
+
module CSP
|
|
8
|
+
# A single, normalized Content-Security-Policy violation report.
|
|
9
|
+
#
|
|
10
|
+
# Browsers emit violation reports in two different wire formats with two
|
|
11
|
+
# different field-naming conventions:
|
|
12
|
+
#
|
|
13
|
+
# - Legacy `application/csp-report` (CSP Level 2/3): a JSON object under a
|
|
14
|
+
# `"csp-report"` key using kebab-case fields (`blocked-uri`,
|
|
15
|
+
# `violated-directive`, `line-number`, ...).
|
|
16
|
+
# - Reporting API `application/reports+json` (Reporting API v1): a JSON
|
|
17
|
+
# ARRAY of `{"type": "csp-violation", "body": {...}}` entries whose body
|
|
18
|
+
# uses camelCase fields (`blockedURL`, `effectiveDirective`,
|
|
19
|
+
# `lineNumber`, ...).
|
|
20
|
+
#
|
|
21
|
+
# This Struct is the single normalized shape both formats collapse into, so
|
|
22
|
+
# application callbacks registered via
|
|
23
|
+
# {Otto::Security::Config#on_csp_violation} never have to care which format
|
|
24
|
+
# the browser used. Build instances with {.from_raw}; use {#to_h} to
|
|
25
|
+
# serialize.
|
|
26
|
+
#
|
|
27
|
+
# SECURITY NOTE: the URL-ish fields (`document_uri`, `blocked_uri`,
|
|
28
|
+
# `referrer`, `source_file`) reflect the page the browser was on and the
|
|
29
|
+
# resource it tried to load. In some applications these can carry sensitive
|
|
30
|
+
# path/query data (tokens, secret links). Otto does NOT redact these — it
|
|
31
|
+
# normalizes and hands them to your callback verbatim. If your application
|
|
32
|
+
# logs or forwards reports, redact these fields in your callback per your
|
|
33
|
+
# own privacy policy before they reach a log sink.
|
|
34
|
+
#
|
|
35
|
+
# @example Reading fields in a callback
|
|
36
|
+
# config.on_csp_violation do |report|
|
|
37
|
+
# logger.warn("CSP violation: #{report.violated_directive} " \
|
|
38
|
+
# "blocked #{report.blocked_uri}")
|
|
39
|
+
# end
|
|
40
|
+
Report = Struct.new(
|
|
41
|
+
:document_uri,
|
|
42
|
+
:referrer,
|
|
43
|
+
:blocked_uri,
|
|
44
|
+
:violated_directive,
|
|
45
|
+
:effective_directive,
|
|
46
|
+
:original_policy,
|
|
47
|
+
:disposition,
|
|
48
|
+
:source_file,
|
|
49
|
+
:status_code,
|
|
50
|
+
:script_sample,
|
|
51
|
+
:line_number,
|
|
52
|
+
:column_number,
|
|
53
|
+
keyword_init: true
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Normalization behavior for {Report}. Reopened (rather than defined inside
|
|
57
|
+
# the Struct.new block) so the constants below are plain class constants,
|
|
58
|
+
# not constants-defined-in-a-block.
|
|
59
|
+
class Report
|
|
60
|
+
# Map from a normalized field to the list of raw keys (in priority order)
|
|
61
|
+
# that may hold its value across both wire formats. Legacy kebab-case
|
|
62
|
+
# keys are listed alongside their Reporting API camelCase equivalents.
|
|
63
|
+
FIELD_ALIASES = {
|
|
64
|
+
document_uri: %w[document-uri documentURL],
|
|
65
|
+
referrer: %w[referrer referer],
|
|
66
|
+
blocked_uri: %w[blocked-uri blockedURL],
|
|
67
|
+
violated_directive: %w[violated-directive violatedDirective],
|
|
68
|
+
effective_directive: %w[effective-directive effectiveDirective],
|
|
69
|
+
original_policy: %w[original-policy originalPolicy],
|
|
70
|
+
disposition: %w[disposition],
|
|
71
|
+
source_file: %w[source-file sourceFile],
|
|
72
|
+
status_code: %w[status-code statusCode],
|
|
73
|
+
script_sample: %w[script-sample sample],
|
|
74
|
+
line_number: %w[line-number lineNumber],
|
|
75
|
+
column_number: %w[column-number columnNumber],
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
# Fields coerced to an Integer (or nil) rather than kept as whatever
|
|
79
|
+
# scalar the browser sent.
|
|
80
|
+
NUMERIC_FIELDS = %i[status_code line_number column_number].freeze
|
|
81
|
+
|
|
82
|
+
# Build a normalized Report from a single raw per-violation field hash.
|
|
83
|
+
#
|
|
84
|
+
# Accepts either wire format's field naming. `violated_directive` and
|
|
85
|
+
# `effective_directive` are cross-filled from each other when only one is
|
|
86
|
+
# present, because the two formats disagree on which they send (legacy
|
|
87
|
+
# favors `violated-directive`; the Reporting API favors
|
|
88
|
+
# `effectiveDirective`).
|
|
89
|
+
#
|
|
90
|
+
# @param raw [Hash] a single violation's field hash (already unwrapped
|
|
91
|
+
# from any `csp-report`/`body` envelope by the parser).
|
|
92
|
+
# @return [Report, nil] the normalized report, or nil when `raw` is not a
|
|
93
|
+
# usable Hash.
|
|
94
|
+
def self.from_raw(raw)
|
|
95
|
+
return nil unless raw.is_a?(Hash)
|
|
96
|
+
|
|
97
|
+
attrs = FIELD_ALIASES.each_with_object({}) do |(field, keys), acc|
|
|
98
|
+
value = first_present(raw, keys)
|
|
99
|
+
acc[field] = NUMERIC_FIELDS.include?(field) ? coerce_int(value) : value
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
cross_fill_directives!(attrs)
|
|
103
|
+
new(**attrs)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# First non-nil value among the given keys, in priority order.
|
|
107
|
+
#
|
|
108
|
+
# @param raw [Hash]
|
|
109
|
+
# @param keys [Array<String>]
|
|
110
|
+
# @return [Object, nil]
|
|
111
|
+
def self.first_present(raw, keys)
|
|
112
|
+
keys.each do |key|
|
|
113
|
+
value = raw[key]
|
|
114
|
+
return value unless value.nil?
|
|
115
|
+
end
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Coerce a raw value to an Integer, or nil when it is not a clean
|
|
120
|
+
# integer. Guards against a browser sending a huge or non-numeric value.
|
|
121
|
+
#
|
|
122
|
+
# @param value [Object]
|
|
123
|
+
# @return [Integer, nil]
|
|
124
|
+
def self.coerce_int(value)
|
|
125
|
+
return nil if value.nil?
|
|
126
|
+
return value if value.is_a?(Integer)
|
|
127
|
+
|
|
128
|
+
str = value.to_s
|
|
129
|
+
str.match?(/\A-?\d{1,18}\z/) ? str.to_i : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Populate a missing directive from its sibling so callbacks can rely on
|
|
133
|
+
# both `violated_directive` and `effective_directive` being present when
|
|
134
|
+
# the browser sent at least one.
|
|
135
|
+
#
|
|
136
|
+
# @param attrs [Hash]
|
|
137
|
+
# @return [void]
|
|
138
|
+
def self.cross_fill_directives!(attrs)
|
|
139
|
+
attrs[:violated_directive] ||= attrs[:effective_directive]
|
|
140
|
+
attrs[:effective_directive] ||= attrs[:violated_directive]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private_class_method :first_present, :coerce_int, :cross_fill_directives!
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|