otto 2.3.0 → 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 (44) 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 +33 -10
  5. data/.github/workflows/claude.yml +9 -2
  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 +84 -0
  12. data/Gemfile +4 -1
  13. data/Gemfile.lock +23 -15
  14. data/README.md +96 -0
  15. data/Rakefile +21 -0
  16. data/docs/.gitignore +1 -0
  17. data/docs/migrating/v2.3.0.md +50 -3
  18. data/docs/reverse-proxy-network-services.md +358 -0
  19. data/examples/caddy_tls_demo/README.md +100 -0
  20. data/examples/caddy_tls_demo/app.rb +41 -0
  21. data/examples/caddy_tls_demo/config.ru +31 -0
  22. data/examples/caddy_tls_demo/routes +9 -0
  23. data/examples/caddy_tls_demo/standalone.ru +38 -0
  24. data/lib/otto/caddy_tls/core.rb +74 -0
  25. data/lib/otto/caddy_tls/localhost_guard.rb +158 -0
  26. data/lib/otto/caddy_tls/server.rb +149 -0
  27. data/lib/otto/caddy_tls.rb +7 -0
  28. data/lib/otto/core/configuration.rb +9 -1
  29. data/lib/otto/core/middleware_management.rb +7 -7
  30. data/lib/otto/core/middleware_stack.rb +39 -5
  31. data/lib/otto/core/router.rb +4 -8
  32. data/lib/otto/security/config.rb +293 -2
  33. data/lib/otto/security/configurator.rb +53 -0
  34. data/lib/otto/security/core.rb +62 -0
  35. data/lib/otto/security/csp/parser.rb +120 -0
  36. data/lib/otto/security/csp/report.rb +147 -0
  37. data/lib/otto/security/csp/report_middleware.rb +120 -0
  38. data/lib/otto/security/csp.rb +19 -0
  39. data/lib/otto/security/middleware/ip_privacy_middleware.rb +72 -7
  40. data/lib/otto/security.rb +1 -0
  41. data/lib/otto/utils.rb +133 -18
  42. data/lib/otto/version.rb +1 -1
  43. data/lib/otto.rb +26 -3
  44. metadata +24 -3
@@ -72,14 +72,10 @@ class Otto
72
72
  path_info = '/' if path_info.to_s.empty?
73
73
 
74
74
  begin
75
- path_info_clean = path_info
76
- .encode(
77
- 'UTF-8', # Target encoding
78
- invalid: :replace, # Replace invalid byte sequences
79
- undef: :replace, # Replace characters undefined in UTF-8
80
- replace: '' # Use empty string for replacement
81
- )
82
- .gsub(%r{/$}, '') # Remove trailing slash, if present
75
+ # Shared with Otto::CaddyTLS::LocalhostGuard so the guard and the
76
+ # router cannot normalize a path differently (which would be a guard
77
+ # bypass). See Otto::Utils.normalize_path.
78
+ path_info_clean = Otto::Utils.normalize_path(env['PATH_INFO'])
83
79
  rescue ArgumentError => e
84
80
  # Log the error but don't expose details
85
81
  Otto.logger.error '[Otto.handle_request] Path encoding error'
@@ -37,6 +37,18 @@ class Otto
37
37
  hop count, not both.
38
38
  MSG
39
39
 
40
+ # Forwarded-header sources depth mode (#trusted_proxy_depth) can count
41
+ # hops from: X-Forwarded-For (default), the RFC 7239 Forwarded header, or
42
+ # Both (Forwarded when present, else X-Forwarded-For). Mirrors
43
+ # OneTimeSecret's site.network.trusted_proxy.header. Only consulted in
44
+ # depth mode; CIDR-walk is unaffected.
45
+ TRUSTED_PROXY_HEADERS = %w[X-Forwarded-For Forwarded Both].freeze
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
+
40
52
  # Error raised when CSRF protection is enabled in production without an
41
53
  # explicitly configured secret. A randomly-generated per-process secret
42
54
  # silently breaks token verification across workers and restarts, so we
@@ -56,7 +68,8 @@ class Otto
56
68
  :trusted_proxies, :require_secure_cookies,
57
69
  :security_headers,
58
70
  :csp_nonce_enabled, :debug_csp, :mcp_auth,
59
- :ip_privacy_config, :trusted_proxy_depth
71
+ :ip_privacy_config, :trusted_proxy_depth, :trusted_proxy_header,
72
+ :csp_report_uri, :csp_report_to_url, :csp_violation_callback
60
73
 
61
74
  # Initialize security configuration with safe defaults
62
75
  #
@@ -73,11 +86,16 @@ class Otto
73
86
  @trusted_proxies = []
74
87
  @trusted_proxy_matchers = []
75
88
  @trusted_proxy_depth = nil
89
+ @trusted_proxy_header = 'X-Forwarded-For'
76
90
  @require_secure_cookies = false
77
91
  @security_headers = default_security_headers
78
92
  @input_validation = true
79
93
  @csp_nonce_enabled = false
80
94
  @debug_csp = false
95
+ @csp_policy = nil
96
+ @csp_report_uri = nil
97
+ @csp_report_to_url = nil
98
+ @csp_violation_callback = nil
81
99
  @rate_limiting_config = { custom_rules: {} }
82
100
  @ip_privacy_config = Otto::Privacy::Config.new
83
101
 
@@ -225,6 +243,27 @@ class Otto
225
243
  @trusted_proxy_depth = depth
226
244
  end
227
245
 
246
+ # Select which forwarded header depth mode counts hops from:
247
+ # 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
248
+ # consulted when depth mode is active (#trusted_proxy_depth_mode?);
249
+ # CIDR-walk always uses X-Forwarded-For / X-Real-IP / X-Client-IP.
250
+ #
251
+ # The value is matched case-insensitively (surrounding whitespace ignored)
252
+ # and stored in its canonical spelling, so a hand-edited config can write
253
+ # `forwarded` or `both` without surprise. A genuinely unrecognized value
254
+ # fails loud at assignment (rather than silently resolving from the wrong
255
+ # header, the way a permissive default would), so a typo surfaces at config
256
+ # time instead of as subtly-wrong client IPs at request time.
257
+ #
258
+ # @param header [String] one of TRUSTED_PROXY_HEADERS (case-insensitive)
259
+ # @raise [FrozenError] if configuration is frozen
260
+ # @raise [ArgumentError] if header is not a recognized value
261
+ def trusted_proxy_header=(header)
262
+ ensure_not_frozen!
263
+
264
+ @trusted_proxy_header = canonicalize_trusted_proxy_header(header)
265
+ end
266
+
228
267
  # Validate that a request size is within acceptable limits
229
268
  #
230
269
  # @param content_length [String, Integer, nil] Content-Length header value
@@ -314,7 +353,8 @@ class Otto
314
353
  def enable_csp!(policy = "default-src 'self'")
315
354
  ensure_not_frozen!
316
355
 
317
- @security_headers['content-security-policy'] = policy
356
+ @csp_policy = policy
357
+ @security_headers['content-security-policy'] = build_static_csp(policy)
318
358
  end
319
359
 
320
360
  # Enable Content Security Policy (CSP) with nonce support
@@ -360,6 +400,110 @@ class Otto
360
400
  @debug_csp
361
401
  end
362
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
+
363
507
  # Generate a CSP policy string with the provided nonce
364
508
  #
365
509
  # @param nonce [String] The nonce value to include in the CSP
@@ -367,6 +511,10 @@ class Otto
367
511
  # @return [String] Complete CSP policy string
368
512
  def generate_nonce_csp(nonce, development_mode: false)
369
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
370
518
  directives.join(' ')
371
519
  end
372
520
 
@@ -453,6 +601,42 @@ class Otto
453
601
  raise ArgumentError, "trusted_proxy_depth must be >= 0, got #{depth}" if depth.negative?
454
602
  end
455
603
 
604
+ # Canonicalize a candidate trusted_proxy_header value: match it
605
+ # case-insensitively (ignoring surrounding whitespace) against the
606
+ # recognized set and return the canonical spelling. Liberal in the spelling
607
+ # it accepts (e.g. 'forwarded' => 'Forwarded') but fail-loud on a genuinely
608
+ # unrecognized value, so a typo is caught at config time rather than
609
+ # silently resolving the client IP from the wrong header.
610
+ #
611
+ # @param header [Object] candidate value
612
+ # @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
613
+ # @return [String] the canonical header value
614
+ def canonicalize_trusted_proxy_header(header)
615
+ candidate = header.to_s.strip
616
+ canonical = TRUSTED_PROXY_HEADERS.find { |allowed| allowed.casecmp?(candidate) }
617
+ return canonical if canonical
618
+
619
+ raise ArgumentError,
620
+ "trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
621
+ end
622
+
623
+ # Strictly validate a stored trusted_proxy_header value against the allowed
624
+ # set. The eager #trusted_proxy_header= setter already canonicalizes, so by
625
+ # freeze time the value is canonical; this freeze-time backstop catches a
626
+ # value smuggled in through a direct-ivar path that bypassed the setter,
627
+ # failing loud rather than silently mis-resolving the client IP at request
628
+ # time.
629
+ #
630
+ # @param header [Object] candidate value
631
+ # @raise [ArgumentError] if header is not one of TRUSTED_PROXY_HEADERS
632
+ # @return [void]
633
+ def validate_trusted_proxy_header!(header)
634
+ return if TRUSTED_PROXY_HEADERS.include?(header)
635
+
636
+ raise ArgumentError,
637
+ "trusted_proxy_header must be one of #{TRUSTED_PROXY_HEADERS.join(', ')}, got #{header.inspect}"
638
+ end
639
+
456
640
  # Validate trusted-proxy configuration coherence at freeze time.
457
641
  #
458
642
  # The eager setters (#trusted_proxy_depth=, #add_trusted_proxy) already
@@ -464,6 +648,7 @@ class Otto
464
648
  # trusted_proxies and a depth >= 1 are configured
465
649
  # @return [void]
466
650
  def validate_trusted_proxy_config!
651
+ validate_trusted_proxy_header!(@trusted_proxy_header)
467
652
  validate_trusted_proxy_depth!(@trusted_proxy_depth)
468
653
  return if @trusted_proxy_depth.nil?
469
654
 
@@ -639,6 +824,112 @@ class Otto
639
824
  defined?(Otto) && Otto.respond_to?(:env?) && Otto.env?(:production, :prod)
640
825
  end
641
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
+
642
933
  # Generate CSP directives for development environment
643
934
  #
644
935
  # Development mode allows inline scripts/styles and hot reloading connections
@@ -39,6 +39,9 @@ class Otto
39
39
  # @param trusted_proxy_depth [Integer, nil] Count-based proxy depth ("trust
40
40
  # the last N hops") for non-enumerable proxy tiers; mutually exclusive
41
41
  # with trusted_proxies (validated at configuration freeze)
42
+ # @param trusted_proxy_header [String, nil] Forwarded header depth mode
43
+ # counts hops from: 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239),
44
+ # or 'Both'. Only consulted in depth mode.
42
45
  # @param security_headers [Hash] Custom security headers to merge with defaults
43
46
  # @param hsts [Boolean] Enable HTTP Strict Transport Security
44
47
  # @param csp [Boolean, String] Enable Content Security Policy
@@ -62,6 +65,7 @@ class Otto
62
65
  rate_limiting: false,
63
66
  trusted_proxies: [],
64
67
  trusted_proxy_depth: nil,
68
+ trusted_proxy_header: nil,
65
69
  security_headers: {},
66
70
  hsts: false,
67
71
  csp: false,
@@ -74,6 +78,7 @@ class Otto
74
78
 
75
79
  Array(trusted_proxies).each { |proxy| add_trusted_proxy(proxy) }
76
80
  self.trusted_proxy_depth = trusted_proxy_depth unless trusted_proxy_depth.nil?
81
+ self.trusted_proxy_header = trusted_proxy_header unless trusted_proxy_header.nil?
77
82
  self.security_headers = security_headers unless security_headers.empty?
78
83
 
79
84
  enable_hsts! if hsts
@@ -142,6 +147,16 @@ class Otto
142
147
  @security_config.trusted_proxy_depth = depth
143
148
  end
144
149
 
150
+ # Select which forwarded header depth mode counts hops from:
151
+ # 'X-Forwarded-For' (default), 'Forwarded' (RFC 7239), or 'Both'. Only
152
+ # consulted when depth mode is active. Mirrors OneTimeSecret's
153
+ # site.network.trusted_proxy.header.
154
+ #
155
+ # @param header [String] one of Otto::Security::Config::TRUSTED_PROXY_HEADERS
156
+ def trusted_proxy_header=(header)
157
+ @security_config.trusted_proxy_header = header
158
+ end
159
+
145
160
  # Set custom security headers that will be added to all responses.
146
161
  # These merge with the default security headers.
147
162
  #
@@ -183,6 +198,44 @@ class Otto
183
198
  @security_config.enable_csp_with_nonce!(debug: debug)
184
199
  end
185
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
+
186
239
  # Add a single authentication strategy
187
240
  #
188
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