dispatch-rails 0.7.0 → 0.8.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 300eb860cc2bb5f7dff2322818cc6bd552507695c71c84c9be81ccbd6e34a3fd
4
- data.tar.gz: 4070748d3d36256a456e9eb960d36c47418ec55f699e0984a9fd49c4d67b501f
3
+ metadata.gz: eb09ad358ca9afa12624a37603693b0892b3816a1733852a6e5bddec3f7e0ad8
4
+ data.tar.gz: 6d877756e23368e5bc033fb4696709b464eb957a1b76a3106e37e1c876cb91db
5
5
  SHA512:
6
- metadata.gz: 1dcbd3e9a81c078086e62e5e50bca81679832a2dff5e5fd1fe5b4a82bc052e445adb83dbc8e1f7c22b8699e94d6a7d5a6d843d2ae7f974a97cabf682289d767d
7
- data.tar.gz: 21765b8506a454e84d225b8d932ce6a15dc2cddd948d8247cddae59908cfe1499139f607a90892e1dffa247a299f156b3c09e1100a73efeb2ae8243af9a7a133
6
+ metadata.gz: 02e6876ac14a61b0215ae79eb52bf47fe632e3dd3ed676c9706921552e1fc73d3a208f21b07047484b9ee8c3e9a5aa9eb58d018b83f17bf80ce6c0609ac95ebe
7
+ data.tar.gz: c22f0667ea5fa682f27f2273f1c3630cc54ffa657694830342dea94b7f5e6c693492b0e82cb006438c7373a2cf6be1a7be3e16203f5927440a9ebb5f54963a24
data/README.md CHANGED
@@ -128,6 +128,28 @@ quote. With `c.annotate_error_body = true`, the same fields (`dispatch_request_i
128
128
  they sit beside `type`/`title`/`detail`). The middleware never changes status codes
129
129
  and passes through anything it can't safely parse.
130
130
 
131
+ ### Browser CSP & Reporting API
132
+
133
+ CSP violations and other browser-native reports (NEL, deprecation, intervention)
134
+ never reach `window.onerror`, so the SDK captures them two ways — pick **one** for
135
+ CSP, or a violation reported through both is counted twice. Both are opt-in.
136
+
137
+ ```ruby
138
+ # 1. Client-side: the JS error tracker also listens for SecurityPolicyViolationEvents.
139
+ # Needs dispatch_error_tracker_tag in your layout (it already loads the tracker).
140
+ c.capture_csp_violations = true
141
+
142
+ # 2. Server-side: a Rack endpoint accepts the browser's NATIVE report POSTs — point a
143
+ # CSP report-uri/report-to (or any Reporting-Endpoints group) at the path below and
144
+ # the browser posts straight to Dispatch. No host controller required.
145
+ c.capture_browser_reports = true
146
+ c.reporting_endpoint_path = "/dispatch/reports" # default; must be a path no route uses
147
+ ```
148
+
149
+ Option 2 also ingests non-CSP Reporting-API types (deprecation, intervention, NEL).
150
+ Reports flow through `error_sample_rate` and `before_send` like any other event, so
151
+ host-specific noise (browser-extension schemes, autofill) is best filtered there.
152
+
131
153
  ### Curated reports (programmatic / agent)
132
154
 
133
155
  A consumer (or an AI agent inside your app) turns a failure into a tracked report:
@@ -106,6 +106,45 @@
106
106
  };
107
107
  }
108
108
 
109
+ // CSP violations never reach window.onerror — the browser fires a dedicated
110
+ // SecurityPolicyViolationEvent. We synthesize a single stack frame from the
111
+ // violation's source location so the dashboard can point at the offending file.
112
+ function buildCspEvent(e) {
113
+ var directive = e.effectiveDirective || e.violatedDirective || "policy";
114
+ var blocked = e.blockedURI || "inline";
115
+ var frames = (e.sourceFile && e.lineNumber) ? [{
116
+ function: "?", filename: e.sourceFile, abs_path: e.sourceFile,
117
+ lineno: parseInt(e.lineNumber, 10) || 0, colno: parseInt(e.columnNumber, 10) || 0,
118
+ in_app: String(e.sourceFile).indexOf("http") === 0
119
+ }] : [];
120
+ return {
121
+ event_id: uuid(),
122
+ timestamp: now(),
123
+ platform: "javascript",
124
+ // report-only disposition is monitoring, not a live block — keep it quieter.
125
+ level: e.disposition === "report" ? "info" : "warning",
126
+ environment: cfg.environment,
127
+ release: cfg.release,
128
+ exception: { values: [{
129
+ type: "SecurityPolicyViolation",
130
+ value: (directive + " blocked " + blocked).slice(0, 2000),
131
+ mechanism: { type: "securitypolicyviolation", handled: false },
132
+ stacktrace: { frames: frames }
133
+ }] },
134
+ breadcrumbs: { values: breadcrumbs.slice() },
135
+ user_path: userPath(),
136
+ request: { url: e.documentURI || location.href, headers: { "User-Agent": navigator.userAgent } },
137
+ user: cfg.user || null,
138
+ tags: Object.assign({
139
+ csp: "true",
140
+ blocked_uri: blocked,
141
+ violated_directive: e.violatedDirective || directive,
142
+ effective_directive: directive,
143
+ disposition: e.disposition || "enforce"
144
+ }, cfg.tags || {})
145
+ };
146
+ }
147
+
109
148
  function sampledOut() {
110
149
  var rate = cfg.sampleRate == null ? 1 : cfg.sampleRate;
111
150
  return Math.random() > rate;
@@ -143,6 +182,21 @@
143
182
  send(buildEvent(r.name || "UnhandledRejection", value, r.stack));
144
183
  });
145
184
 
185
+ // Opt-in (cfg.captureCsp): a permissive or report-only policy can fire these in
186
+ // bulk, so we dedupe per page — a violation repeated on every render (e.g. a
187
+ // blocked image) is reported once. Bounded so a page spraying unique blocked
188
+ // URIs can't grow the key set without limit.
189
+ if (cfg.captureCsp) {
190
+ var seenCsp = {};
191
+ var seenCspCount = 0;
192
+ document.addEventListener("securitypolicyviolation", function (e) {
193
+ var key = [e.effectiveDirective, e.blockedURI, e.sourceFile, e.lineNumber].join("|");
194
+ if (seenCsp[key]) return;
195
+ if (seenCspCount < 200) { seenCsp[key] = 1; seenCspCount++; }
196
+ send(buildCspEvent(e));
197
+ });
198
+ }
199
+
146
200
  // Manual capture API: window.Dispatch.captureException(error)
147
201
  window.Dispatch = window.Dispatch || {};
148
202
  window.Dispatch.captureException = function (err) {
@@ -35,7 +35,8 @@ module Dispatch
35
35
  end
36
36
 
37
37
  # Render the browser exception tracker (captures uncaught JS errors and
38
- # unhandled promise rejections). Place once in your layout's <head>.
38
+ # unhandled promise rejections and, when config.capture_csp_violations is on,
39
+ # SecurityPolicyViolationEvents). Place once in your layout's <head>.
39
40
  def dispatch_error_tracker_tag(tags: {})
40
41
  config = Dispatch::Rails.configuration
41
42
  return nil if config.errors_only? # no browser surface in an API-only app
@@ -55,6 +56,7 @@ module Dispatch
55
56
  release: config.release,
56
57
  sampleRate: config.error_sample_rate,
57
58
  captureClicks: config.capture_clicks,
59
+ captureCsp: config.capture_csp_violations,
58
60
  user: normalized_user,
59
61
  tags: tags
60
62
  }.to_json
@@ -16,6 +16,8 @@ module Dispatch
16
16
  # Exception tracking
17
17
  attr_accessor :capture_exceptions, :capture_browser_errors, :error_endpoint,
18
18
  :environment, :release, :enabled_environments, :error_sample_rate, :before_send
19
+ # Browser security/reporting capture (both opt-in; high-volume + noise-prone)
20
+ attr_accessor :capture_csp_violations, :capture_browser_reports, :reporting_endpoint_path
19
21
  # Process lifecycle (crash-at-exit capture, rake failures, shutdown flush)
20
22
  attr_accessor :capture_at_exit, :shutdown_timeout
21
23
  # Structured error responses (API-only)
@@ -49,6 +51,16 @@ module Dispatch
49
51
  @error_sample_rate = 1.0
50
52
  @before_send = nil # ->(event) { event or nil to drop }
51
53
 
54
+ # Browser security/reporting capture. Both opt-in: a permissive or
55
+ # report-only CSP can emit these in bulk, and capture_browser_reports also
56
+ # makes the gem own a URL path. Pick ONE CSP mechanism — the JS
57
+ # securitypolicyviolation listener (capture_csp_violations) OR the native
58
+ # report endpoint (capture_browser_reports) — or a violation reported
59
+ # through both is counted twice.
60
+ @capture_csp_violations = false # JS: listen for SecurityPolicyViolationEvent
61
+ @capture_browser_reports = false # Server: accept native browser report POSTs
62
+ @reporting_endpoint_path = "/dispatch/reports"
63
+
52
64
  # Process lifecycle. Report the exception killing the process (a crash
53
65
  # during boot, a dying runner) and drain the send queue before exit so
54
66
  # deploys/restarts don't drop captured events.
@@ -120,6 +132,13 @@ module Dispatch
120
132
  configured? && @capture_exceptions
121
133
  end
122
134
 
135
+ # Fast-path guard for ReportingEndpointMiddleware: own the reporting path only
136
+ # when the host opted in and we have credentials to deliver. Otherwise the
137
+ # middleware passes the request straight through (no surprise 204s).
138
+ def browser_reports_enabled?
139
+ configured? && @capture_browser_reports
140
+ end
141
+
123
142
  # Heartbeats piggyback on the same gating as error capture, plus their own
124
143
  # toggle. Off in non-enabled environments (so dev/test never phone home).
125
144
  def traffic_tracking_enabled?
@@ -31,6 +31,14 @@ module Dispatch
31
31
  initializer "dispatch-rails.error_capture" do |app|
32
32
  app.config.middleware.use Dispatch::Rails::Middleware
33
33
 
34
+ # Native browser Reporting API endpoint (CSP report-uri/report-to, NEL,
35
+ # deprecation, …). Mounted unconditionally; owns
36
+ # config.reporting_endpoint_path only when the host opts in via
37
+ # config.capture_browser_reports, and passes through otherwise. Sits just
38
+ # inside the capture middleware so a report POST short-circuits to 204
39
+ # before the heartbeat/router layers below it.
40
+ app.config.middleware.use Dispatch::Rails::ReportingEndpointMiddleware
41
+
34
42
  # Catch background/non-request errors (ActiveJob, runners, handle blocks).
35
43
  if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
36
44
  ::Rails.error.subscribe(Dispatch::Rails::ErrorSubscriber.new)
@@ -0,0 +1,172 @@
1
+ require "json"
2
+
3
+ module Dispatch
4
+ module Rails
5
+ # A report the browser delivered through the native Reporting API (deprecation,
6
+ # intervention, NEL, …) — not a Ruby error and not a JS exception.
7
+ class BrowserReport < StandardError; end
8
+ # A Content-Security-Policy violation delivered by the browser's native
9
+ # report-uri/report-to channel. CSP violations never reach window.onerror, so
10
+ # nothing surfaces them unless the browser is told to POST a report here.
11
+ class CspViolation < BrowserReport; end
12
+
13
+ # Terminal Rack endpoint for the browser Reporting API. Point a CSP
14
+ # `report-uri`/`report-to` group (or any Reporting-Endpoints group) at
15
+ # config.reporting_endpoint_path and the browser POSTs its reports straight
16
+ # here — no host controller required. Each report becomes a synthetic exception
17
+ # pushed through Reporter.capture, so it lands as a first-class event with the
18
+ # usual sampling, before_send, and transport applied.
19
+ #
20
+ # Opt-in (config.capture_browser_reports). When disabled — or for any request
21
+ # that isn't a POST to the configured path — it passes straight through. When
22
+ # it does handle a request it answers 204 itself and never calls downstream, so
23
+ # the configured path must be one no host route uses.
24
+ #
25
+ # Pick ONE CSP mechanism: this endpoint OR the JS securitypolicyviolation
26
+ # listener (config.capture_csp_violations). Enabling both, with report-uri
27
+ # aimed here, double-counts every violation.
28
+ class ReportingEndpointMiddleware
29
+ MAX_BODY_BYTES = 64_000
30
+ MAX_REPORTS = 50
31
+
32
+ def initialize(app)
33
+ @app = app
34
+ end
35
+
36
+ def call(env)
37
+ config = Dispatch::Rails.configuration
38
+ return @app.call(env) unless handles?(config, env)
39
+
40
+ # @app.call above is deliberately OUTSIDE any rescue — a normal request's
41
+ # exceptions must propagate to Rails' exception handling, never be
42
+ # swallowed here. Only the report-handling path is rescued.
43
+ handle_report(env)
44
+ end
45
+
46
+ private
47
+
48
+ def handle_report(env)
49
+ capture_reports(env)
50
+ no_content
51
+ rescue StandardError => e
52
+ # A report endpoint must never error the browser's beacon — always 204.
53
+ warn "[dispatch-rails] reporting endpoint failed: #{e.class}: #{e.message}"
54
+ no_content
55
+ end
56
+
57
+ # A FRESH Rack triple per call — downstream middleware mutates the array and
58
+ # headers, so a shared/frozen response would raise or corrupt across requests.
59
+ def no_content
60
+ [204, { "Content-Type" => "text/plain" }, []]
61
+ end
62
+
63
+ def handles?(config, env)
64
+ config.browser_reports_enabled? &&
65
+ env["REQUEST_METHOD"] == "POST" &&
66
+ path_match?(config, env["PATH_INFO"])
67
+ end
68
+
69
+ def path_match?(config, path)
70
+ target = config.reporting_endpoint_path.to_s
71
+ return false if target.empty?
72
+
73
+ path == target || path == "#{target}/"
74
+ end
75
+
76
+ def capture_reports(env)
77
+ raw = read_body(env)
78
+ return if raw.empty?
79
+
80
+ parse_reports(content_type(env), raw).first(MAX_REPORTS).each do |report|
81
+ capture_one(report, env)
82
+ end
83
+ end
84
+
85
+ def capture_one(report, env)
86
+ type = report[:type].to_s
87
+ body = report[:body].is_a?(Hash) ? report[:body] : {}
88
+ csp?(type) ? capture_csp(body, env) : capture_generic(type, body, env)
89
+ end
90
+
91
+ def capture_csp(body, env)
92
+ fields = csp_fields(body)
93
+ directive = fields[:effective_directive] || fields[:violated_directive] || "policy"
94
+ blocked = fields[:blocked_uri] || "inline"
95
+ Reporter.capture(
96
+ CspViolation.new("#{directive} blocked #{blocked}"),
97
+ handled: true, env: env,
98
+ # report-only disposition is monitoring, not a live block — keep it quieter.
99
+ level: fields[:disposition].to_s == "report" ? "info" : "warning",
100
+ context: { tags: { csp: "true", report_type: "csp-violation" }.merge(fields) }
101
+ )
102
+ end
103
+
104
+ def capture_generic(type, body, env)
105
+ label = type.empty? ? "unknown" : type
106
+ Reporter.capture(
107
+ BrowserReport.new("Browser report: #{label}"),
108
+ handled: true, env: env, level: "info",
109
+ context: { tags: { report_type: label }.merge(stringify(body)) }
110
+ )
111
+ end
112
+
113
+ def csp?(type)
114
+ type == "csp-violation" || type == "csp"
115
+ end
116
+
117
+ # Normalize the two CSP spellings: report-uri's hyphen-case
118
+ # (blocked-uri/document-uri) and the Reporting API's camelCase, which also
119
+ # renamed the *-uri fields to *URL.
120
+ def csp_fields(body)
121
+ {
122
+ blocked_uri: body["blocked-uri"] || body["blockedURI"] || body["blockedURL"],
123
+ document_uri: body["document-uri"] || body["documentURI"] || body["documentURL"],
124
+ violated_directive: body["violated-directive"] || body["violatedDirective"],
125
+ effective_directive: body["effective-directive"] || body["effectiveDirective"],
126
+ source_file: body["source-file"] || body["sourceFile"],
127
+ line_number: body["line-number"] || body["lineNumber"],
128
+ column_number: body["column-number"] || body["columnNumber"],
129
+ disposition: body["disposition"]
130
+ }.compact
131
+ end
132
+
133
+ # Legacy report-uri sends a single { "csp-report": {...} } object; the
134
+ # Reporting API sends an array of { "type", "body" } under reports+json.
135
+ def parse_reports(content_type, raw)
136
+ data = JSON.parse(raw)
137
+ if content_type.include?("reports+json") || data.is_a?(Array)
138
+ Array(data).map { |r| { type: r["type"], body: r["body"] } }
139
+ elsif data.is_a?(Hash) && data.key?("csp-report")
140
+ [{ type: "csp-violation", body: data["csp-report"] }]
141
+ elsif data.is_a?(Hash)
142
+ [{ type: "csp-violation", body: data }]
143
+ else
144
+ []
145
+ end
146
+ rescue JSON::ParserError
147
+ []
148
+ end
149
+
150
+ def read_body(env)
151
+ input = env["rack.input"]
152
+ return "" unless input
153
+
154
+ raw = input.read(MAX_BODY_BYTES).to_s
155
+ input.rewind if input.respond_to?(:rewind)
156
+ raw
157
+ rescue StandardError
158
+ ""
159
+ end
160
+
161
+ def content_type(env)
162
+ (env["CONTENT_TYPE"] || env["HTTP_CONTENT_TYPE"]).to_s
163
+ end
164
+
165
+ def stringify(hash)
166
+ return {} unless hash.is_a?(Hash)
167
+
168
+ hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
169
+ end
170
+ end
171
+ end
172
+ end
@@ -1,5 +1,5 @@
1
1
  module Dispatch
2
2
  module Rails
3
- VERSION = "0.7.0".freeze
3
+ VERSION = "0.8.1".freeze
4
4
  end
5
5
  end
@@ -4,6 +4,7 @@ require "dispatch/rails/event_builder"
4
4
  require "dispatch/rails/transport"
5
5
  require "dispatch/rails/reporter"
6
6
  require "dispatch/rails/middleware"
7
+ require "dispatch/rails/reporting_endpoint_middleware"
7
8
  require "dispatch/rails/response_annotator"
8
9
  require "dispatch/rails/heartbeat_aggregator"
9
10
  require "dispatch/rails/heartbeat_middleware"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dispatch-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dispatch Team
@@ -55,6 +55,7 @@ files:
55
55
  - lib/dispatch/rails/middleware.rb
56
56
  - lib/dispatch/rails/rake_handler.rb
57
57
  - lib/dispatch/rails/reporter.rb
58
+ - lib/dispatch/rails/reporting_endpoint_middleware.rb
58
59
  - lib/dispatch/rails/response_annotator.rb
59
60
  - lib/dispatch/rails/transport.rb
60
61
  - lib/dispatch/rails/version.rb