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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/dependabot.yml +1 -1
  3. data/.github/workflows/ci.yml +7 -1
  4. data/.github/workflows/claude-code-review.yml +32 -9
  5. data/.github/workflows/claude.yml +7 -5
  6. data/.github/workflows/code-smells.yml +2 -2
  7. data/.github/workflows/release-gem.yml +12 -2
  8. data/.github/workflows/ruby-lint.yml +66 -0
  9. data/.github/workflows/yardoc.yml +117 -0
  10. data/.yardopts +15 -0
  11. data/CHANGELOG.rst +59 -0
  12. data/Gemfile +4 -2
  13. data/Gemfile.lock +23 -17
  14. data/README.md +96 -0
  15. data/docs/.gitignore +1 -0
  16. data/docs/reverse-proxy-network-services.md +358 -0
  17. data/examples/caddy_tls_demo/README.md +100 -0
  18. data/examples/caddy_tls_demo/app.rb +41 -0
  19. data/examples/caddy_tls_demo/config.ru +31 -0
  20. data/examples/caddy_tls_demo/routes +9 -0
  21. data/examples/caddy_tls_demo/standalone.ru +38 -0
  22. data/lib/otto/caddy_tls/core.rb +74 -0
  23. data/lib/otto/caddy_tls/localhost_guard.rb +158 -0
  24. data/lib/otto/caddy_tls/server.rb +149 -0
  25. data/lib/otto/caddy_tls.rb +7 -0
  26. data/lib/otto/core/middleware_management.rb +7 -7
  27. data/lib/otto/core/middleware_stack.rb +39 -5
  28. data/lib/otto/core/router.rb +4 -8
  29. data/lib/otto/security/config.rb +227 -2
  30. data/lib/otto/security/configurator.rb +38 -0
  31. data/lib/otto/security/core.rb +62 -0
  32. data/lib/otto/security/csp/parser.rb +120 -0
  33. data/lib/otto/security/csp/report.rb +147 -0
  34. data/lib/otto/security/csp/report_middleware.rb +120 -0
  35. data/lib/otto/security/csp.rb +19 -0
  36. data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
  37. data/lib/otto/security.rb +1 -0
  38. data/lib/otto/utils.rb +36 -0
  39. data/lib/otto/version.rb +1 -1
  40. data/lib/otto.rb +26 -3
  41. metadata +23 -3
@@ -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
- @security_headers['content-security-policy'] = policy
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.
@@ -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