dispatch-rails 0.10.0 → 0.10.2

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: 0fab7390093499294d3150088b49f04fc9fca920a711b33d1e8455e3a6ad2ea9
4
- data.tar.gz: ba51af0c31c876c4d482445cb33353d56f97c5afb2a2dfc07d4a29e06a15d69d
3
+ metadata.gz: 5f742aa0ca8db65f9db9c6b5a135ae7d926fdf57dffde8d1d011ae3449298d32
4
+ data.tar.gz: e7b3df1d90a904e8c2383a03d3231c0ff7398b4f269632d697c5d728c42c386a
5
5
  SHA512:
6
- metadata.gz: dce4062b5290d5a1e82f3c7bdf01d73a279ac4fa01a63431ed2ed3f97e58f2592229fd02d09db26cf811c1a774e2edcadb5ea82d1bb1654b75918d886685c70c
7
- data.tar.gz: b7d09382ab99f1962a6ea92007364f5798d7cda3c33f1bd92cc111c6895de49554d655ca9d8b87a2d1978d0812cbf5e29cdc66e4821a766db8e3c8e71e29b11c
6
+ metadata.gz: b9cb75b00f87e29843b6837051100d1cac96448ab6db77a048837303b62cd98d18a1ab7dac1bd1f25a8f527b17cf644f2dab6ef46a4a62cf7edc88f1b02d71fd
7
+ data.tar.gz: f409e09011d0492ccf0ea168ac6f89129ee6fcb83431617a5560ea977c1872a4346586996679538db4b14ea3ae50396d95d47df42f4626f77fff59cd3a00d804
data/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ All notable changes to `dispatch-rails` are documented here. The format is based
4
4
  on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.10.2] - 2026-06-18
8
+
9
+ ### Fixed
10
+ - The browser tracker/widget bootstrapped via an **inline** `<script>` (a nonced
11
+ `javascript_tag` that ran `import("/dispatch/…")`). Inline scripts are fragile
12
+ under Turbo + CSP — the nonce attribute is blanked after parse and must be
13
+ re-applied from the `csp-nonce` meta tag on every navigation — so hosts kept
14
+ seeing `script-src-elem blocked inline`. Both loaders are now plain **external
15
+ same-origin** `<script type="module" src="/dispatch/…">` tags, which
16
+ `script-src 'self'` allows outright — no nonce, no `'unsafe-inline'`, no Turbo
17
+ nonce drift. The nonce is still attached when the host generates one, so strict
18
+ nonce-only / `strict-dynamic` policies keep working. (`type="module"` also lets
19
+ the browser de-dupe the load across Turbo navigations via the module map.)
20
+ - Browser CSP/Reporting-API events were attributed to the `/dispatch/reports`
21
+ endpoint the browser POSTs to, collapsing every violation across the app onto
22
+ one bogus URL. CSP reports now carry the violation's `document-uri` as the event
23
+ URL, so the dashboard groups and links them to the page that actually violated.
24
+
25
+ ## [0.10.1] - 2026-06-18
26
+
27
+ ### Fixed
28
+ - The browser tracker/widget loaders import a fixed `/dispatch/error_tracker.js`
29
+ and `/dispatch/widget.js`, but nothing served those exact paths — the asset
30
+ pipeline only exposes the files under `/assets/...` (digested under Propshaft),
31
+ so the dynamic `import()` 404'd in every environment and the tracker/widget
32
+ silently never loaded. A new `AssetMiddleware` now serves both files at the
33
+ imported paths with a JS content type, requiring no host mount, route, or
34
+ precompile entry. (The config island stays a non-executable
35
+ `<script type="application/json">` and the loaders keep their nonce-aware
36
+ `javascript_tag`, so no CSP nonce is needed for either.)
37
+
7
38
  ## [0.10.0] - 2026-06-18
8
39
 
9
40
  ### Added
@@ -1,9 +1,9 @@
1
1
  <script type="application/json" id="dispatch-error-config"><%= raw error_config_json.gsub("</", "<\\/") %></script>
2
- <%# nonce: true picks up the host app's CSP nonce; the JSON config island above is non-executable and needs none %>
3
- <%= javascript_tag type: "module", nonce: true do %>
4
- if (!window.__dispatchErrorTrackerLoaded) {
5
- import("/dispatch/error_tracker.js").catch(() => {
6
- console.error("[dispatch-rails] failed to load error_tracker.js ensure the engine assets are mounted");
7
- });
8
- }
9
- <% end %>
2
+ <%# The config island above is a non-executable application/json data block, so CSP
3
+ never applies to it. The tracker itself loads as an EXTERNAL same-origin script
4
+ (served by the engine's AssetMiddleware at this exact path), which script-src
5
+ 'self' allows on its own — no nonce, no 'unsafe-inline', and none of the inline-
6
+ script-under-Turbo nonce drift that an inline loader is prone to. We still attach
7
+ the nonce when the host generates one, so strict nonce-only / strict-dynamic
8
+ policies (where 'self' alone isn't enough) keep working. %>
9
+ <%= tag.script type: "module", src: "/dispatch/error_tracker.js", nonce: content_security_policy_nonce %>
@@ -60,12 +60,10 @@
60
60
  </div>
61
61
  </div>
62
62
 
63
- <%# nonce: true picks up the host app's CSP nonce; omitted when no nonce generator is configured %>
64
- <%= javascript_tag type: "module", nonce: true do %>
65
- if (!window.__dispatchWidgetLoaded) {
66
- window.__dispatchWidgetLoaded = true;
67
- import("/dispatch/widget.js").catch(() => {
68
- console.error("[dispatch-rails] failed to load widget.js make sure the engine assets are mounted");
69
- });
70
- }
71
- <% end %>
63
+ <%# Loaded as an EXTERNAL same-origin module (served by the engine's AssetMiddleware
64
+ at this exact path), so script-src 'self' allows it without a nonce or
65
+ 'unsafe-inline' — no inline loader to be blocked under script-src-elem. type=module
66
+ both resolves widget.js's `import "@hotwired/stimulus"` via the host importmap and
67
+ de-dupes the load across Turbo navigations via the module map. The nonce is
68
+ attached when the host generates one, for strict nonce-only / strict-dynamic CSPs. %>
69
+ <%= tag.script type: "module", src: "/dispatch/widget.js", nonce: content_security_policy_nonce %>
@@ -0,0 +1,65 @@
1
+ module Dispatch
2
+ module Rails
3
+ # Serves the gem's browser JS (error_tracker.js, widget.js) at the stable,
4
+ # non-digested /dispatch/<name>.js paths the view partials import from.
5
+ #
6
+ # The engine also registers the JS directory in config.assets.paths, but the
7
+ # partials deliberately import a fixed URL (so the inline loader needs no
8
+ # asset-pipeline lookup) — and the asset pipeline never exposes that exact URL:
9
+ # Propshaft serves the files under /assets/dispatch/<name>-<digest>.js, Sprockets
10
+ # under /assets/dispatch/<name>.js, and neither at /dispatch/<name>.js. With
11
+ # nothing serving that path the import 404s in every environment and the
12
+ # tracker/widget silently never load. This middleware closes that gap with no
13
+ # host mount, route, or precompile entry required.
14
+ #
15
+ # Registered outermost of the gem's middlewares (see Engine), so a matching GET
16
+ # short-circuits before the capture/heartbeat layers and never counts as traffic.
17
+ # It owns ONLY the two exact paths it knows; every other request passes straight
18
+ # through.
19
+ class AssetMiddleware
20
+ ASSET_DIR = File.expand_path("../../../app/assets/javascripts/dispatch", __dir__).freeze
21
+
22
+ # Exact request path => filename. A fixed allowlist (no path joining from the
23
+ # request) so a crafted PATH_INFO can never traverse out of ASSET_DIR.
24
+ ASSETS = {
25
+ "/dispatch/error_tracker.js" => "error_tracker.js",
26
+ "/dispatch/widget.js" => "widget.js"
27
+ }.freeze
28
+
29
+ def initialize(app)
30
+ @app = app
31
+ end
32
+
33
+ def call(env)
34
+ method = env["REQUEST_METHOD"]
35
+ filename = (method == "GET" || method == "HEAD") && ASSETS[env["PATH_INFO"]]
36
+ return @app.call(env) unless filename
37
+
38
+ serve(File.join(ASSET_DIR, filename), head: method == "HEAD")
39
+ end
40
+
41
+ private
42
+
43
+ def serve(path, head:)
44
+ body = File.binread(path)
45
+ # Lowercase header names: Rack 3 requires it, and middleware above us
46
+ # (Rack::ETag/ConditionalGet) only honors lowercase keys — a capitalized
47
+ # "Cache-Control" gets silently overridden with Rack's private default.
48
+ #
49
+ # Non-digested URL, so don't cache forever — a gem upgrade changes the bytes
50
+ # at the same path. An hour balances revalidation against churn; the host's
51
+ # Rack::ETag/ConditionalGet (outside this middleware) adds a content ETag and
52
+ # turns repeat fetches into 304s for free.
53
+ headers = {
54
+ "content-type" => "text/javascript; charset=utf-8",
55
+ "cache-control" => "public, max-age=3600",
56
+ "content-length" => body.bytesize.to_s
57
+ }
58
+ [200, headers, head ? [] : [body]]
59
+ rescue SystemCallError => e
60
+ warn "[dispatch-rails] failed to serve #{path}: #{e.class}: #{e.message}"
61
+ [404, { "content-type" => "text/plain" }, ["Not found"]]
62
+ end
63
+ end
64
+ end
65
+ end
@@ -23,6 +23,14 @@ module Dispatch
23
23
  if defined?(::Importmap) && app.respond_to?(:importmap)
24
24
  # Host app can pin "dispatch-widget" / "dispatch-error-tracker" in its importmap.
25
25
  end
26
+
27
+ # Serve error_tracker.js / widget.js at the /dispatch/<name>.js paths the
28
+ # view partials import from. Added first (so it's the OUTERMOST of the gem's
29
+ # middlewares — config.middleware.use appends, and this initializer runs
30
+ # before the capture/heartbeat ones below), letting a matching GET
31
+ # short-circuit before those layers. The asset pipeline only ever exposes
32
+ # these under /assets/..., so without this the import 404s everywhere.
33
+ app.config.middleware.use Dispatch::Rails::AssetMiddleware
26
34
  end
27
35
 
28
36
  # Server-side exception capture. The middleware is added last (innermost),
@@ -16,17 +16,23 @@ module Dispatch
16
16
  MAX_PARAMS_BYTES = 8_000
17
17
  SAFE_HEADERS = %w[User-Agent Referer Accept Content-Type Host X-Request-Id].freeze
18
18
 
19
- def self.call(exception, handled:, env: nil, user: nil, tags: {}, level: "error")
20
- new(exception, handled: handled, env: env, user: user, tags: tags, level: level).call
19
+ def self.call(exception, handled:, env: nil, user: nil, tags: {}, level: "error", request_url: nil)
20
+ new(exception, handled: handled, env: env, user: user, tags: tags, level: level,
21
+ request_url: request_url).call
21
22
  end
22
23
 
23
- def initialize(exception, handled:, env: nil, user: nil, tags: {}, level: "error")
24
+ def initialize(exception, handled:, env: nil, user: nil, tags: {}, level: "error", request_url: nil)
24
25
  @exception = exception
25
26
  @handled = handled
26
27
  @env = env
27
28
  @user = user
28
29
  @tags = tags || {}
29
30
  @level = level
31
+ # Override for the event's request URL. A browser report (CSP, NEL, …) is
32
+ # POSTed to /dispatch/reports, so the env URL is that endpoint, not the page
33
+ # that violated the policy — the caller passes the report's document-uri here
34
+ # so the dashboard attributes (and groups) the event to the real page.
35
+ @request_url_override = request_url
30
36
  @source_cache = {}
31
37
  @config = Dispatch::Rails.configuration
32
38
  end
@@ -221,6 +227,8 @@ module Dispatch
221
227
  end
222
228
 
223
229
  def request_url
230
+ return @request_url_override if @request_url_override.present?
231
+
224
232
  scheme = @env["rack.url_scheme"] || "http"
225
233
  host = @env["HTTP_HOST"] || @env["SERVER_NAME"]
226
234
  return nil if host.nil?
@@ -21,7 +21,8 @@ module Dispatch
21
21
  user = resolve_user(config, env, context)
22
22
  tags = merged_tags(config, env, context)
23
23
  event = EventBuilder.call(exception, handled: handled, env: env, user: user,
24
- tags: tags, level: level)
24
+ tags: tags, level: level,
25
+ request_url: context[:request_url])
25
26
  event = config.before_send.call(event) if config.before_send.respond_to?(:call)
26
27
  return if event.nil?
27
28
 
@@ -57,7 +57,10 @@ module Dispatch
57
57
  # A FRESH Rack triple per call — downstream middleware mutates the array and
58
58
  # headers, so a shared/frozen response would raise or corrupt across requests.
59
59
  def no_content
60
- [204, { "Content-Type" => "text/plain" }, []]
60
+ # Lowercase header name for Rack 3 conformance (it requires lowercase
61
+ # response header field names; capitalized keys can be dropped by
62
+ # downstream middleware).
63
+ [204, { "content-type" => "text/plain" }, []]
61
64
  end
62
65
 
63
66
  def handles?(config, env)
@@ -97,7 +100,13 @@ module Dispatch
97
100
  handled: true, env: env,
98
101
  # report-only disposition is monitoring, not a live block — keep it quieter.
99
102
  level: fields[:disposition].to_s == "report" ? "info" : "warning",
100
- context: { tags: { csp: "true", report_type: "csp-violation" }.merge(fields) }
103
+ context: {
104
+ # Attribute the violation to the page it happened on, not to this
105
+ # /dispatch/reports endpoint the browser POSTed the report to — otherwise
106
+ # every CSP violation across the app collapses onto one bogus URL.
107
+ request_url: fields[:document_uri],
108
+ tags: { csp: "true", report_type: "csp-violation" }.merge(fields)
109
+ }
101
110
  )
102
111
  end
103
112
 
@@ -16,8 +16,11 @@ module Dispatch
16
16
  # never changes the status code, never swallows a propagating exception, and
17
17
  # passes through untouched anything it cannot safely parse.
18
18
  class ResponseAnnotator
19
- HEADER_REQUEST_ID = "X-Dispatch-Request-Id"
20
- HEADER_REPORT_URL = "X-Dispatch-Report-Url"
19
+ # Lowercase header names for Rack 3 conformance. HTTP header names are
20
+ # case-insensitive to clients, so emitting them lowercase changes nothing
21
+ # the caller sees, while satisfying Rack 3's lowercase-only requirement.
22
+ HEADER_REQUEST_ID = "x-dispatch-request-id"
23
+ HEADER_REPORT_URL = "x-dispatch-report-url"
21
24
  BODY_REQUEST_ID = "dispatch_request_id"
22
25
  BODY_REPORT_URL = "dispatch_report_url"
23
26
 
@@ -100,7 +103,7 @@ module Dispatch
100
103
  def replace_content_length!(headers, bytesize)
101
104
  headers.delete("Content-Length")
102
105
  headers.delete("content-length")
103
- headers["Content-Length"] = bytesize.to_s
106
+ headers["content-length"] = bytesize.to_s
104
107
  end
105
108
  end
106
109
  end
@@ -1,5 +1,5 @@
1
1
  module Dispatch
2
2
  module Rails
3
- VERSION = "0.10.0".freeze
3
+ VERSION = "0.10.2".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/asset_middleware"
7
8
  require "dispatch/rails/reporting_endpoint_middleware"
8
9
  require "dispatch/rails/response_annotator"
9
10
  require "dispatch/rails/heartbeat_aggregator"
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.10.0
4
+ version: 0.10.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dispatch Team
@@ -47,6 +47,7 @@ files:
47
47
  - app/views/dispatch/_error_tracker.html.erb
48
48
  - app/views/dispatch/_widget.html.erb
49
49
  - lib/dispatch-rails.rb
50
+ - lib/dispatch/rails/asset_middleware.rb
50
51
  - lib/dispatch/rails/configuration.rb
51
52
  - lib/dispatch/rails/engine.rb
52
53
  - lib/dispatch/rails/error_subscriber.rb