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 +4 -4
- data/CHANGELOG.md +31 -0
- data/app/views/dispatch/_error_tracker.html.erb +8 -8
- data/app/views/dispatch/_widget.html.erb +7 -9
- data/lib/dispatch/rails/asset_middleware.rb +65 -0
- data/lib/dispatch/rails/engine.rb +8 -0
- data/lib/dispatch/rails/event_builder.rb +11 -3
- data/lib/dispatch/rails/reporter.rb +2 -1
- data/lib/dispatch/rails/reporting_endpoint_middleware.rb +11 -2
- data/lib/dispatch/rails/response_annotator.rb +6 -3
- data/lib/dispatch/rails/version.rb +1 -1
- data/lib/dispatch-rails.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5f742aa0ca8db65f9db9c6b5a135ae7d926fdf57dffde8d1d011ae3449298d32
|
|
4
|
+
data.tar.gz: e7b3df1d90a904e8c2383a03d3231c0ff7398b4f269632d697c5d728c42c386a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
<%#
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
<%#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
20
|
-
|
|
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["
|
|
106
|
+
headers["content-length"] = bytesize.to_s
|
|
104
107
|
end
|
|
105
108
|
end
|
|
106
109
|
end
|
data/lib/dispatch-rails.rb
CHANGED
|
@@ -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.
|
|
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
|