rails-profiler 0.30.4 → 0.30.6
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 +445 -0
- data/app/assets/builds/profiler-toolbar.js +2 -1
- data/app/assets/builds/profiler.js +2 -1
- data/app/controllers/profiler/api/ajax_controller.rb +0 -2
- data/app/controllers/profiler/api/cluster_controller.rb +0 -2
- data/app/controllers/profiler/api/console_controller.rb +0 -2
- data/app/controllers/profiler/api/env_vars_controller.rb +0 -2
- data/app/controllers/profiler/api/events_controller.rb +0 -1
- data/app/controllers/profiler/api/explain_controller.rb +0 -2
- data/app/controllers/profiler/api/function_profiling_controller.rb +0 -2
- data/app/controllers/profiler/api/jobs_controller.rb +0 -2
- data/app/controllers/profiler/api/outbound_http_controller.rb +0 -2
- data/app/controllers/profiler/api/profiles_controller.rb +0 -2
- data/app/controllers/profiler/api/slave_proxy_controller.rb +0 -2
- data/app/controllers/profiler/api/test_runner_controller.rb +0 -2
- data/app/controllers/profiler/api/tests_controller.rb +0 -2
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -1
- data/app/controllers/profiler/application_controller.rb +33 -2
- data/app/controllers/profiler/assets_controller.rb +3 -0
- data/app/controllers/profiler/profiles_controller.rb +0 -9
- data/lib/profiler/cluster/master_client.rb +4 -2
- data/lib/profiler/cluster/slave_proxy.rb +1 -0
- data/lib/profiler/configuration.rb +22 -4
- data/lib/profiler/instrumentation/thread_context_propagation.rb +25 -10
- data/lib/profiler/local_request.rb +163 -0
- data/lib/profiler/middleware/cors_middleware.rb +47 -35
- data/lib/profiler/railtie.rb +4 -5
- data/lib/profiler/version.rb +1 -1
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7a2ea61f4463670763c8c3c2d92f98a1f4ff2917fb9a2f5e0d245ea1860d2f1
|
|
4
|
+
data.tar.gz: ba467072b67f027565a29c99f458cd959933de512cdfa054ddde052d3e65ddb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b2a1049428bab86d06ba54bba7cd373bfc3ebe606137bd2e6bfda46d5b1501c10574ebcec632125efd9e5c6b6a0be2d11c763b15e670a06aaaac384c0e028c6e
|
|
7
|
+
data.tar.gz: 561590eb7eadc20139ea79a0005efefbf9de88da0010db4ae20903360d3199342ba9648fe9d6d4bc1452c62e18c61f47311f19bb78f68a3307791f3089a11f06
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
Write your entry under `[Unreleased]`, for a user of the gem. When your branch is
|
|
9
|
+
ready, run `bin/changelog stamp`: it works out the version the merge will publish
|
|
10
|
+
and turns `[Unreleased]` into `[X.Y.Z] - <date>`, reopening an empty one above.
|
|
11
|
+
Run it again after a rebase or a new commit, it corrects the number and folds the
|
|
12
|
+
new entries in. Never edit a section that already carries a tag.
|
|
13
|
+
|
|
14
|
+
A section written by `stamp` carries `<!-- stamped -->`. In the older sections,
|
|
15
|
+
each bullet carries the short sha of the commit it describes, and commits left out
|
|
16
|
+
on purpose are listed in that version's `excluded:` comment with the reason; all of
|
|
17
|
+
it in HTML comments, invisible once rendered. `bin/changelog coverage` checks that
|
|
18
|
+
every commit of every tag interval is accounted for one way or the other.
|
|
19
|
+
|
|
20
|
+
## [Unreleased]
|
|
21
|
+
|
|
22
|
+
## [0.30.6] - 2026-10-03
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Security
|
|
27
|
+
|
|
28
|
+
- **Access control:** Check `authorization_mode` on every page and endpoint of the profiler (UI,
|
|
29
|
+
API, server-sent events, test runner, toolbar), answering `403` otherwise. It used to decide only
|
|
30
|
+
which requests were captured: with `:allow_authorized` and a block that refused everybody, the API
|
|
31
|
+
still listed and deleted profiles and wrote `ENV`. The gem's static JS and CSS stay public.
|
|
32
|
+
- **Access control:** The new default `authorization_mode`, `:allow_local`, only lets in requests
|
|
33
|
+
made from this machine: a loopback `REMOTE_ADDR`, no forwarding header naming a remote client, and
|
|
34
|
+
a local `Host` (or one listed in `config.hosts`), against DNS rebinding; the test environment
|
|
35
|
+
also accepts the reserved hosts `www.example.com`, `example.com` and `example.org`, so the
|
|
36
|
+
application's request specs are still captured. It also decides
|
|
37
|
+
which requests are captured, and logs the reason for a refusal once per process. The previous
|
|
38
|
+
default, `:allow_all`, let anybody who could reach the application read and change everything.
|
|
39
|
+
- **Access control:** API requests that change something (including a form `POST` turned into
|
|
40
|
+
another verb by `_method`) must carry an `X-Profiler-Request` header or a CSRF token, so that a
|
|
41
|
+
page on another site can no longer trigger them, even when the application turns
|
|
42
|
+
`allow_forgery_protection` off. The dashboard, the toolbar and the cluster send the header.
|
|
43
|
+
- **Access control:** CORS is off by default (`extension_cors_enabled = false`,
|
|
44
|
+
`cors_allowed_origins = []`), and `Access-Control-Allow-Origin: *` is never sent to a request
|
|
45
|
+
carrying a cookie or an `Authorization` header. It used to be `*` for everybody.
|
|
46
|
+
- **Access control:** Profiler pages may only be framed by the profiler itself and by the Chrome
|
|
47
|
+
extension's DevTools panel (`frame-ancestors 'self' chrome-extension: devtools:`, configurable
|
|
48
|
+
with the new `frame_ancestors` option), and send `X-Frame-Options: SAMEORIGIN`. Any website could
|
|
49
|
+
frame them, and with `?embed=true` a profile page carried no framing protection at all.
|
|
50
|
+
- **Upgrading:** Nothing to do when you browse the profiler from the machine that runs Rails. Each
|
|
51
|
+
previous behavior can be restored in `config/initializers/profiler.rb`:
|
|
52
|
+
- Docker, a VM or a remote proxy: admit your network with `config.authorization_mode =
|
|
53
|
+
:allow_authorized` and an `authorize_with` block reading `REMOTE_ADDR` (example in the README), or
|
|
54
|
+
go back to `config.authorization_mode = :allow_all`, which offers no protection.
|
|
55
|
+
- A cluster whose master and slaves run on different machines: each side admits the other's address
|
|
56
|
+
the same way.
|
|
57
|
+
- Your own scripts calling the API: send `X-Profiler-Request: 1`, or set
|
|
58
|
+
`config.api_forgery_protection = false`.
|
|
59
|
+
- Cross-origin clients: `config.extension_cors_enabled = true` and
|
|
60
|
+
`config.cors_allowed_origins = ["https://your.origin"]`. `["*"]` restores the behavior before
|
|
61
|
+
0.30.6 and reopens the profiler to every website you visit: under `:allow_local` or `:allow_all`,
|
|
62
|
+
any page open in your browser can read its data and change it.
|
|
63
|
+
- Framing by other sites: `config.frame_ancestors = ["'self'", "http:", "https:"]`.
|
|
64
|
+
|
|
65
|
+
## [0.30.5] - 2026-10-03
|
|
66
|
+
|
|
67
|
+
<!-- stamped -->
|
|
68
|
+
|
|
69
|
+
### Fixed
|
|
70
|
+
|
|
71
|
+
- **Instrumentation:** Pass the arguments of `Thread.new` on to its block, and keep keyword
|
|
72
|
+
arguments as keywords. While a profile was being collected the block got none at all, which on
|
|
73
|
+
Ruby 3.4 broke the Happy Eyeballs hostname resolution of `Socket.tcp`: connections by hostname
|
|
74
|
+
timed out, for example as `Redis::CannotConnectError`. Keyword arguments were flattened at all
|
|
75
|
+
times, on every Ruby. Affects v0.22.1 through v0.30.4.
|
|
76
|
+
- **Instrumentation:** Raise `ThreadError` on a `Thread.new` with no block during a profiled
|
|
77
|
+
request, as Ruby does.
|
|
78
|
+
|
|
79
|
+
## [0.30.4] - 2026-07-01
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **Cluster:** Resolve profiles across slaves from the master `ProfilesController` <!-- 9236c4c -->
|
|
84
|
+
|
|
85
|
+
## [0.30.3] - 2026-06-29
|
|
86
|
+
|
|
87
|
+
### Fixed
|
|
88
|
+
|
|
89
|
+
- Wrap the profile dashboards and `TestRunnerPage` with `QueryClientProvider` <!-- 420ae2f -->
|
|
90
|
+
|
|
91
|
+
## [0.30.2] - 2026-06-29
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
|
|
95
|
+
- **Middleware:** Duplicate downstream headers before mutating them <!-- 3efb20c -->
|
|
96
|
+
|
|
97
|
+
## [0.30.1] - 2026-06-29
|
|
98
|
+
|
|
99
|
+
### Fixed
|
|
100
|
+
|
|
101
|
+
- **Cluster:** Fix sentinel mismatch, profile list pagination, nil safety in MCP tools and HTTPS slave URLs <!-- 4b5fb1c -->
|
|
102
|
+
|
|
103
|
+
## [0.30.0] - 2026-06-28
|
|
104
|
+
|
|
105
|
+
### Added
|
|
106
|
+
|
|
107
|
+
- **Cluster:** Add master/slave profiler clustering <!-- 1435f22 -->
|
|
108
|
+
|
|
109
|
+
## [0.29.0] - 2026-06-28
|
|
110
|
+
|
|
111
|
+
### Added
|
|
112
|
+
|
|
113
|
+
- **SSE:** Push live profile updates over server-sent events <!-- fa0ae2e -->
|
|
114
|
+
|
|
115
|
+
## [0.28.0] - 2026-06-06
|
|
116
|
+
|
|
117
|
+
### Added
|
|
118
|
+
|
|
119
|
+
- **HTTP:** Add the `http_backtrace_depth` configuration option <!-- 25b4f99 -->
|
|
120
|
+
|
|
121
|
+
## [0.27.1] - 2026-06-06
|
|
122
|
+
|
|
123
|
+
### Changed
|
|
124
|
+
|
|
125
|
+
- **API:** Replace manual `fetch()` calls with `orval` generated TanStack Query hooks <!-- e33341e -->
|
|
126
|
+
|
|
127
|
+
### Fixed
|
|
128
|
+
|
|
129
|
+
- **File store:** Prevent `ENOENT` errors on concurrent profile file deletion <!-- 8898f0e -->
|
|
130
|
+
|
|
131
|
+
## [0.27.0] - 2026-06-02
|
|
132
|
+
|
|
133
|
+
### Added
|
|
134
|
+
|
|
135
|
+
- **MCP:** Add console profiling, mailer detail, logs/env/i18n sections and full documentation <!-- ca362a1 -->
|
|
136
|
+
|
|
137
|
+
## [0.26.0] - 2026-06-02
|
|
138
|
+
|
|
139
|
+
### Added
|
|
140
|
+
|
|
141
|
+
- **Test profiler:** Add the `run_tests` MCP tool, the `track_tests` option, specs and docs <!-- 265ce03 -->
|
|
142
|
+
- **Test profiler:** Enrich reporter, add SSE streaming, MCP tools and env safety <!-- a410c4b -->
|
|
143
|
+
- **Test profiler:** Add test profiling, test runner UI and MCP tool <!-- a116fcb -->
|
|
144
|
+
|
|
145
|
+
## [0.25.0] - 2026-06-01
|
|
146
|
+
|
|
147
|
+
### Added
|
|
148
|
+
|
|
149
|
+
- **Console:** Add a console profiling tab with IRB instrumentation <!-- f29937a -->
|
|
150
|
+
|
|
151
|
+
### Changed
|
|
152
|
+
|
|
153
|
+
- **Docs:** Document the console profiling tab and the env override behaviour <!-- 031cac6 -->
|
|
154
|
+
|
|
155
|
+
<!-- excluded:
|
|
156
|
+
bd55e9b specs only, no visible change
|
|
157
|
+
-->
|
|
158
|
+
|
|
159
|
+
## [0.24.0] - 2026-06-01
|
|
160
|
+
|
|
161
|
+
### Added
|
|
162
|
+
|
|
163
|
+
- **MCP:** Add tools to manage `ENV` variables <!-- 9126194 -->
|
|
164
|
+
|
|
165
|
+
## [0.23.0] - 2026-05-27
|
|
166
|
+
|
|
167
|
+
### Added
|
|
168
|
+
|
|
169
|
+
- **Profile list:** Add a global clear-all button and auto-refresh on tab switch <!-- 460a110 -->
|
|
170
|
+
|
|
171
|
+
## [0.22.1] - 2026-05-26
|
|
172
|
+
|
|
173
|
+
### Fixed
|
|
174
|
+
|
|
175
|
+
- **HTTP:** Propagate collector to child threads, add fire-and-forget support, fix deep backtrace capture <!-- 9d0bd61 -->
|
|
176
|
+
|
|
177
|
+
## [0.22.0] - 2026-05-04
|
|
178
|
+
|
|
179
|
+
### Added
|
|
180
|
+
|
|
181
|
+
- **Mailer:** Add a body preview, a variables display, a queued badge and design alignment <!-- b7d608f -->
|
|
182
|
+
- **Collector:** Implement `MailerCollector`, tracking ActionMailer deliveries <!-- e59dd72 -->
|
|
183
|
+
|
|
184
|
+
### Changed
|
|
185
|
+
|
|
186
|
+
- **Toolbar:** Remove the emoji from the mailer toolbar item and the dashboard tab <!-- 55df9bb -->
|
|
187
|
+
|
|
188
|
+
### Fixed
|
|
189
|
+
|
|
190
|
+
- **Mailer:** Add the Mailers tab to `JobProfileDashboard` <!-- 4ce145d -->
|
|
191
|
+
- **Mailer:** Fix thread isolation and `deliver_later` mode detection <!-- 6e76a99 -->
|
|
192
|
+
- **Mailer:** Add `MailerCollector` to the job profiler and align the toolbar item design <!-- 915be28 -->
|
|
193
|
+
- **Mailer:** Handle the Rails 7 encoded mail string in the `deliver.action_mailer` payload <!-- 5479dcf -->
|
|
194
|
+
- **Mailer:** Replace `Thread.current.delete` with a read and nil write for fiber local variables <!-- 8787201 -->
|
|
195
|
+
- **Version:** Remove the pre-release suffix, which broke Bundler resolution <!-- 98caecd -->
|
|
196
|
+
- **Mailer:** Address code review findings from MR !32 <!-- b46d3d2 -->
|
|
197
|
+
|
|
198
|
+
## [0.21.0] - 2026-05-02
|
|
199
|
+
|
|
200
|
+
### Added
|
|
201
|
+
|
|
202
|
+
- **Toolbar:** Add a collapse/expand toggle <!-- 8f0669a -->
|
|
203
|
+
|
|
204
|
+
## [0.20.0] - 2026-05-01
|
|
205
|
+
|
|
206
|
+
### Added
|
|
207
|
+
|
|
208
|
+
- **UI:** Display the gem version and warn on a mismatch <!-- 64ff6eb -->
|
|
209
|
+
|
|
210
|
+
## [0.19.2] - 2026-04-24
|
|
211
|
+
|
|
212
|
+
### Fixed
|
|
213
|
+
|
|
214
|
+
- **HTTP:** Capture `RestClient` `body_stream`, remove body size limits, show bodies in MCP tool results <!-- 89fb0e5 -->
|
|
215
|
+
|
|
216
|
+
<!-- excluded:
|
|
217
|
+
4527e12 release plumbing, version placeholder reset
|
|
218
|
+
-->
|
|
219
|
+
|
|
220
|
+
## [0.19.1] - 2026-04-17
|
|
221
|
+
|
|
222
|
+
### Fixed
|
|
223
|
+
|
|
224
|
+
- **Env vars:** Fix revert restoring wrong value, SQLite busy errors, middleware rescue handling and UI refresh <!-- 55c8661 -->
|
|
225
|
+
|
|
226
|
+
<!-- excluded:
|
|
227
|
+
4637391 pipeline only
|
|
228
|
+
b53b4d7 pipeline only
|
|
229
|
+
7b33bf6 pipeline only
|
|
230
|
+
-->
|
|
231
|
+
|
|
232
|
+
## [0.19.0] - 2026-04-15
|
|
233
|
+
|
|
234
|
+
### Added
|
|
235
|
+
|
|
236
|
+
- **Env:** Propagate env var overrides to Sidekiq workers <!-- e75a883 -->
|
|
237
|
+
|
|
238
|
+
## [0.18.0] - 2026-04-14
|
|
239
|
+
|
|
240
|
+
### Changed
|
|
241
|
+
|
|
242
|
+
- **UI:** Extract shared tab utilities and redesign the Request tab <!-- 7aa8adb -->
|
|
243
|
+
|
|
244
|
+
### Fixed
|
|
245
|
+
|
|
246
|
+
- **HTTP:** Capture outbound request body passed as second argument to `http.request()` <!-- 5a59665 -->
|
|
247
|
+
|
|
248
|
+
## [0.17.0] - 2026-04-13
|
|
249
|
+
|
|
250
|
+
### Added
|
|
251
|
+
|
|
252
|
+
- **Dashboard:** Add env tab on the home page and env/job toolbar items <!-- d0277ab -->
|
|
253
|
+
|
|
254
|
+
## [0.16.0] - 2026-04-13
|
|
255
|
+
|
|
256
|
+
### Added
|
|
257
|
+
|
|
258
|
+
- **Function profiler:** Add `stackprof` sampling mode with multi-clock support and GC visibility <!-- 5f73512 -->
|
|
259
|
+
|
|
260
|
+
### Fixed
|
|
261
|
+
|
|
262
|
+
- **Function profiler:** Fix `stackprof` bugs, remove minimal mode, improve performance <!-- 76c6197 -->
|
|
263
|
+
|
|
264
|
+
## [0.15.0] - 2026-04-11
|
|
265
|
+
|
|
266
|
+
### Added
|
|
267
|
+
|
|
268
|
+
- **Env:** Add environment variables tab <!-- 0e85f18 -->
|
|
269
|
+
|
|
270
|
+
## [0.14.0] - 2026-04-09
|
|
271
|
+
|
|
272
|
+
### Added
|
|
273
|
+
|
|
274
|
+
- **Profiler:** Link jobs to their triggering request or parent job <!-- 74ba5e3 -->
|
|
275
|
+
|
|
276
|
+
## [0.13.0] - 2026-04-09
|
|
277
|
+
|
|
278
|
+
### Added
|
|
279
|
+
|
|
280
|
+
- **UI:** Add copy/download buttons on HTTP bodies, waterfall view, date sort, and other UX improvements <!-- 5afd003 -->
|
|
281
|
+
- **UI:** Always show waterfall above the HTTP request list <!-- 32c6b6d -->
|
|
282
|
+
- **UI:** Use `SmartBodyPreview` in RequestTab for copy/download on request and response bodies <!-- aef4c0c -->
|
|
283
|
+
|
|
284
|
+
### Fixed
|
|
285
|
+
|
|
286
|
+
- **UI:** Derive download file extension from actual MIME type instead of body category <!-- 1dc0bc8 -->
|
|
287
|
+
- **UI:** Move `sortedJobs` after `filteredJobs` to fix TDZ error <!-- d4de7bc -->
|
|
288
|
+
|
|
289
|
+
## [0.12.0] - 2026-04-09
|
|
290
|
+
|
|
291
|
+
### Added
|
|
292
|
+
|
|
293
|
+
- **Profiler:** Add function-level profiling with flamegraph and stats table <!-- fcb04a6 -->
|
|
294
|
+
|
|
295
|
+
## [0.11.1] - 2026-04-09
|
|
296
|
+
|
|
297
|
+
### Added
|
|
298
|
+
|
|
299
|
+
- **Config:** Add `tmp_path` option to centralize the storage directory <!-- 5e77944 -->
|
|
300
|
+
|
|
301
|
+
### Fixed
|
|
302
|
+
|
|
303
|
+
- **MCP:** Decompress gzip+base64 bodies before returning to UI and MCP tool results <!-- 8f5ac47 -->
|
|
304
|
+
|
|
305
|
+
## [0.11.0] - 2026-04-06
|
|
306
|
+
|
|
307
|
+
### Added
|
|
308
|
+
|
|
309
|
+
- **UI:** Add load-more pagination to the profile list <!-- 9f8458b -->
|
|
310
|
+
|
|
311
|
+
## [0.10.1] - 2026-04-06
|
|
312
|
+
|
|
313
|
+
### Changed
|
|
314
|
+
|
|
315
|
+
- **Docs:** Add full user documentation, with a UI guide, an MCP guide and screenshots <!-- 80a9198 -->
|
|
316
|
+
|
|
317
|
+
## [0.10.0] - 2026-04-06
|
|
318
|
+
|
|
319
|
+
### Added
|
|
320
|
+
|
|
321
|
+
- **Middleware:** Add configurable CORS origins, CSP nonce support and body compression <!-- 3a42188 -->
|
|
322
|
+
|
|
323
|
+
## [0.9.1] - 2026-04-06
|
|
324
|
+
|
|
325
|
+
### Removed
|
|
326
|
+
|
|
327
|
+
- **Collectors:** Remove `PerformanceCollector`, replaced by `FlameGraphCollector` <!-- 212e155 -->
|
|
328
|
+
|
|
329
|
+
## [0.9.0] - 2026-04-06
|
|
330
|
+
|
|
331
|
+
### Added
|
|
332
|
+
|
|
333
|
+
- **Profiler:** Add the `Profiler.measure` custom instrumentation API <!-- 0a3044e -->
|
|
334
|
+
|
|
335
|
+
## [0.8.0] - 2026-04-05
|
|
336
|
+
|
|
337
|
+
### Added
|
|
338
|
+
|
|
339
|
+
- **UI:** Add sortable columns, quick filter presets and flamegraph search <!-- ffd2e6f -->
|
|
340
|
+
|
|
341
|
+
## [0.7.0] - 2026-04-05
|
|
342
|
+
|
|
343
|
+
### Added
|
|
344
|
+
|
|
345
|
+
- **Database:** Add N+1 visual detection and `EXPLAIN ANALYZE` integration <!-- a22902d -->
|
|
346
|
+
|
|
347
|
+
## [0.6.0] - 2026-04-05
|
|
348
|
+
|
|
349
|
+
### Added
|
|
350
|
+
|
|
351
|
+
- **MCP:** Reduce token usage with filtering, body file saving and pagination <!-- 834251d -->
|
|
352
|
+
|
|
353
|
+
## [0.5.0] - 2026-04-04
|
|
354
|
+
|
|
355
|
+
### Added
|
|
356
|
+
|
|
357
|
+
- **MCP:** Add outbound IDs, a domain filter, datetimes, a latest token, exceptions and copy helpers <!-- 9330545 -->
|
|
358
|
+
|
|
359
|
+
## [0.4.0] - 2026-04-03
|
|
360
|
+
|
|
361
|
+
### Added
|
|
362
|
+
|
|
363
|
+
- **Collector:** Add an I18n panel tracking translation lookups per request <!-- 3032a15 -->
|
|
364
|
+
|
|
365
|
+
<!-- excluded:
|
|
366
|
+
b50d1eb pipeline only
|
|
367
|
+
-->
|
|
368
|
+
|
|
369
|
+
## [0.3.0] - 2026-04-02
|
|
370
|
+
|
|
371
|
+
### Added
|
|
372
|
+
|
|
373
|
+
- **Collector:** Add a routes panel and enrich the request collector <!-- bdf0e3c -->
|
|
374
|
+
|
|
375
|
+
## [0.2.0] - 2026-03-29
|
|
376
|
+
|
|
377
|
+
### Added
|
|
378
|
+
|
|
379
|
+
- **Collector:** Add a Logs panel, capturing `Rails.logger` messages per request <!-- 0e0e7ce -->
|
|
380
|
+
- Publish the gem to RubyGems.org <!-- f292a21 -->
|
|
381
|
+
|
|
382
|
+
<!-- excluded:
|
|
383
|
+
10c1314 pipeline only, despite the fix: type
|
|
384
|
+
3f97b1d pipeline only
|
|
385
|
+
a8c2044 pipeline only
|
|
386
|
+
27cc550 pipeline only
|
|
387
|
+
-->
|
|
388
|
+
|
|
389
|
+
## [0.1.4] - 2026-03-29
|
|
390
|
+
|
|
391
|
+
### Added
|
|
392
|
+
|
|
393
|
+
- **Profiler:** Capture the full request and response, and add an MCP history clear <!-- 9922c8a -->
|
|
394
|
+
|
|
395
|
+
## [0.1.3] - 2026-03-29
|
|
396
|
+
|
|
397
|
+
### Changed
|
|
398
|
+
|
|
399
|
+
- **Assets:** Migrate Sass `@import` to `@use`, which removes the build deprecation warnings <!-- 1a67e48 -->
|
|
400
|
+
|
|
401
|
+
## [0.1.2] - 2026-03-25
|
|
402
|
+
|
|
403
|
+
This tag holds nothing but the release commit the pipeline wrote. The interval
|
|
404
|
+
carries no change to the gem, so there is nothing to report for this version.
|
|
405
|
+
|
|
406
|
+
## [0.1.1] - 2026-03-25
|
|
407
|
+
|
|
408
|
+
### Added
|
|
409
|
+
|
|
410
|
+
- **HTTP:** Decompress gzip and deflate response bodies before storing them <!-- f71a5c7 -->
|
|
411
|
+
- **Flamegraph:** Add an interactive flame graph tab <!-- 63c4b67 -->
|
|
412
|
+
- **Config:** Add `.well-known` and `favicon.ico` to the default `skip_paths` <!-- 1286447 -->
|
|
413
|
+
- **Storage:** Add an SQLite backend, a blob store and delete/clear APIs <!-- 544015b -->
|
|
414
|
+
- **UI:** Add filters to the profile list action bar and fix the badge colours <!-- 40d4081 -->
|
|
415
|
+
- **HTTP:** Add binary download, inline preview and smart text formatting <!-- b09eab8 -->
|
|
416
|
+
- Rename the HTTP tab, add the outbound HTTP list and extend MCP coverage <!-- 71bc537 -->
|
|
417
|
+
- **Jobs:** Add background job profiling for Sidekiq and ActiveJob <!-- 27b87db -->
|
|
418
|
+
- Add an outbound HTTP request collector <!-- e5f2ac9 -->
|
|
419
|
+
- **MCP:** Migrate to the official Ruby SDK, over HTTP transport <!-- 3fc2d45 -->
|
|
420
|
+
- Implement the dashboard UI, with a tab system and a design system <!-- beb83f7 -->
|
|
421
|
+
- Add the AJAX and dump collectors, and the CORS middleware <!-- 08bad9f -->
|
|
422
|
+
- First implementation of the gem <!-- 360c6fa -->
|
|
423
|
+
|
|
424
|
+
### Changed
|
|
425
|
+
|
|
426
|
+
- **Docs:** Document installation from the GitLab Package Registry <!-- 527c32b -->
|
|
427
|
+
- **Assets:** Serve every gem asset exclusively under `/_profiler/` <!-- e8ae1ae -->
|
|
428
|
+
- **Config:** Add `manifest.json` to the default `skip_paths` <!-- 350f16c -->
|
|
429
|
+
- **Docs:** Correct the installation instructions in the README and remove the dashboard README <!-- e44c8a2 -->
|
|
430
|
+
|
|
431
|
+
### Fixed
|
|
432
|
+
|
|
433
|
+
- **Packaging:** Fix the gemspec metadata and file glob, and exclude source assets <!-- 195b609 -->
|
|
434
|
+
- **Packaging:** Switch to the yarn build scripts and update dependencies <!-- b62f2d8 -->
|
|
435
|
+
- **Packaging:** Fix the `http_collector` require, the collectors option override and asset serving in API only applications <!-- 729f20f -->
|
|
436
|
+
- **Packaging:** Exclude `.gem` build artifacts from the file list <!-- 9b97f60 -->
|
|
437
|
+
- **Profiler:** Improve memory tracking and profile deserialisation <!-- a0af47e -->
|
|
438
|
+
|
|
439
|
+
<!-- excluded:
|
|
440
|
+
e27cdae pipeline only
|
|
441
|
+
02d63c9 pipeline only
|
|
442
|
+
9f598d9 subject is not conventional, tooling only
|
|
443
|
+
7d1d823 tests only
|
|
444
|
+
c0d46e6 licence file, not a change to the gem
|
|
445
|
+
-->
|
|
@@ -4167,10 +4167,11 @@
|
|
|
4167
4167
|
const { method, params, data, headers = {}, signal } = config;
|
|
4168
4168
|
const url = resolvedUrl;
|
|
4169
4169
|
const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
|
|
4170
|
+
const baseHeaders = { "X-Profiler-Request": "1", ...headers };
|
|
4170
4171
|
const res = await fetch(url + qs, {
|
|
4171
4172
|
method,
|
|
4172
4173
|
signal,
|
|
4173
|
-
headers: data !== void 0 ? { "Content-Type": "application/json", ...
|
|
4174
|
+
headers: data !== void 0 ? { "Content-Type": "application/json", ...baseHeaders } : baseHeaders,
|
|
4174
4175
|
body: data !== void 0 ? JSON.stringify(data) : void 0
|
|
4175
4176
|
});
|
|
4176
4177
|
if (res.status === 204) return {};
|
|
@@ -3456,10 +3456,11 @@
|
|
|
3456
3456
|
const { method, params, data, headers = {}, signal } = config;
|
|
3457
3457
|
const url = resolvedUrl;
|
|
3458
3458
|
const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
|
|
3459
|
+
const baseHeaders = { "X-Profiler-Request": "1", ...headers };
|
|
3459
3460
|
const res = await fetch(url + qs, {
|
|
3460
3461
|
method,
|
|
3461
3462
|
signal,
|
|
3462
|
-
headers: data !== void 0 ? { "Content-Type": "application/json", ...
|
|
3463
|
+
headers: data !== void 0 ? { "Content-Type": "application/json", ...baseHeaders } : baseHeaders,
|
|
3463
3464
|
body: data !== void 0 ? JSON.stringify(data) : void 0
|
|
3464
3465
|
});
|
|
3465
3466
|
if (res.status === 204) return {};
|
|
@@ -5,8 +5,6 @@ require "profiler/explain_runner"
|
|
|
5
5
|
module Profiler
|
|
6
6
|
module Api
|
|
7
7
|
class ExplainController < ApplicationController
|
|
8
|
-
skip_before_action :verify_authenticity_token
|
|
9
|
-
|
|
10
8
|
def create
|
|
11
9
|
unless Profiler.configuration.enabled
|
|
12
10
|
return render json: { error: "EXPLAIN is only available when the profiler is enabled" }, status: :forbidden
|
|
@@ -5,8 +5,6 @@ require_relative "../../../../lib/profiler/cluster/slave_proxy"
|
|
|
5
5
|
module Profiler
|
|
6
6
|
module Api
|
|
7
7
|
class SlaveProxyController < Profiler::ApplicationController
|
|
8
|
-
skip_before_action :verify_authenticity_token
|
|
9
|
-
|
|
10
8
|
def forward
|
|
11
9
|
proxy = Cluster::SlaveProxy.new(params[:slave_name])
|
|
12
10
|
sub_path = params[:path].to_s
|
|
@@ -8,8 +8,6 @@ module Profiler
|
|
|
8
8
|
class TestRunnerController < ApplicationController
|
|
9
9
|
include ActionController::Live
|
|
10
10
|
|
|
11
|
-
skip_before_action :verify_authenticity_token
|
|
12
|
-
|
|
13
11
|
def files
|
|
14
12
|
framework = params[:framework]
|
|
15
13
|
tree = Profiler::TestRunner::Discovery.files(framework: framework)
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
|
-
protect_from_forgery with: :exception
|
|
6
|
-
|
|
7
5
|
layout "profiler/application"
|
|
8
6
|
|
|
9
7
|
before_action :check_authorization
|
|
8
|
+
before_action :authorize_request
|
|
9
|
+
# Declared after the access checks, so that an unauthorized request gets its 403 first.
|
|
10
|
+
protect_from_forgery with: :exception
|
|
10
11
|
|
|
11
12
|
private
|
|
12
13
|
|
|
@@ -16,6 +17,36 @@ module Profiler
|
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
def authorize_request
|
|
21
|
+
return if Profiler.configuration.authorized?(request)
|
|
22
|
+
|
|
23
|
+
deny("Not authorized to access the profiler")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Decided by the profiler's own setting, not by the application's allow_forgery_protection,
|
|
27
|
+
# which the config/environments/test.rb generated by Rails turns off.
|
|
28
|
+
def protect_against_forgery?
|
|
29
|
+
Profiler.configuration.api_forgery_protection ? true : false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Rails' token, or the header every profiler client sends. A third-party page cannot add a
|
|
33
|
+
# custom header without a CORS preflight, which is refused unless an origin was allowed.
|
|
34
|
+
def verified_request?
|
|
35
|
+
super || request.headers[Profiler::FORGERY_PROTECTION_HEADER].present?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_unverified_request
|
|
39
|
+
deny("Missing #{Profiler::FORGERY_PROTECTION_HEADER} header or CSRF token")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def deny(message)
|
|
43
|
+
if controller_path.start_with?("profiler/api/")
|
|
44
|
+
render json: { error: message }, status: :forbidden
|
|
45
|
+
else
|
|
46
|
+
render plain: message, status: :forbidden
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
19
50
|
def build_child_jobs(profile)
|
|
20
51
|
storage = @resolved_storage || Profiler.storage
|
|
21
52
|
storage.find_by_parent(profile.token)
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
class AssetsController < ApplicationController
|
|
5
|
+
# The gem's own static JS and CSS: no application data, cached publicly. Every piece of
|
|
6
|
+
# data they display comes from the API, which keeps the authorization check.
|
|
5
7
|
skip_before_action :verify_authenticity_token
|
|
6
8
|
skip_before_action :check_authorization
|
|
9
|
+
skip_before_action :authorize_request
|
|
7
10
|
|
|
8
11
|
def toolbar_js
|
|
9
12
|
path = Profiler::Engine.root.join("app", "assets", "builds", "profiler-toolbar.js")
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
class ProfilesController < ApplicationController
|
|
5
|
-
before_action :allow_iframe_embedding, only: [:show], if: -> { params[:embed] == "true" }
|
|
6
|
-
|
|
7
5
|
def index
|
|
8
6
|
limit = params[:limit]&.to_i || 50
|
|
9
7
|
offset = params[:offset]&.to_i || 0
|
|
@@ -158,13 +156,6 @@ module Profiler
|
|
|
158
156
|
end
|
|
159
157
|
end
|
|
160
158
|
|
|
161
|
-
def allow_iframe_embedding
|
|
162
|
-
response.headers.delete('X-Frame-Options')
|
|
163
|
-
# Don't set frame-ancestors CSP to allow Chrome extensions to embed
|
|
164
|
-
# Mark that CSP should not be set by middleware
|
|
165
|
-
request.env['profiler.skip_csp'] = true
|
|
166
|
-
end
|
|
167
|
-
|
|
168
159
|
def filter_profiles(profiles)
|
|
169
160
|
filtered = profiles
|
|
170
161
|
|
|
@@ -24,7 +24,8 @@ module Profiler
|
|
|
24
24
|
config = Profiler.configuration
|
|
25
25
|
uri = URI("#{config.master_url}/_profiler/api/cluster/register")
|
|
26
26
|
body = { name: config.resolved_name, url: config.self_url }.to_json
|
|
27
|
-
resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json"
|
|
27
|
+
resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json",
|
|
28
|
+
Profiler::FORGERY_PROTECTION_HEADER => "1")
|
|
28
29
|
unless resp.code.to_i.between?(200, 299)
|
|
29
30
|
raise "Master returned #{resp.code}: #{resp.body.to_s.slice(0, 200)}"
|
|
30
31
|
end
|
|
@@ -36,7 +37,8 @@ module Profiler
|
|
|
36
37
|
config = Profiler.configuration
|
|
37
38
|
uri = URI("#{config.master_url}/_profiler/api/cluster/heartbeat")
|
|
38
39
|
body = { name: config.resolved_name }.to_json
|
|
39
|
-
resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json"
|
|
40
|
+
resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json",
|
|
41
|
+
Profiler::FORGERY_PROTECTION_HEADER => "1")
|
|
40
42
|
raise "Heartbeat rejected #{resp.code}" unless resp.code.to_i.between?(200, 299)
|
|
41
43
|
end
|
|
42
44
|
|
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "local_request"
|
|
4
|
+
|
|
3
5
|
module Profiler
|
|
6
|
+
# Header the profiler's own clients send on every request. A page on another origin cannot
|
|
7
|
+
# add it without a CORS preflight, which the profiler does not grant by default, so its
|
|
8
|
+
# presence proves the request is not a cross-site forgery.
|
|
9
|
+
FORGERY_PROTECTION_HEADER = "X-Profiler-Request"
|
|
10
|
+
|
|
4
11
|
class Configuration
|
|
5
12
|
attr_accessor :enabled, :storage_options, :collectors,
|
|
6
13
|
:skip_paths, :slow_query_threshold, :max_queries_warning,
|
|
7
14
|
:track_memory, :memory_warning_threshold,
|
|
8
15
|
:mcp_enabled, :mcp_transport, :mcp_port,
|
|
9
16
|
:authorization_mode, :max_profiles, :extension_cors_enabled,
|
|
10
|
-
:cors_allowed_origins,
|
|
17
|
+
:cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
|
|
11
18
|
:track_ajax, :ajax_skip_paths,
|
|
12
19
|
:track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
|
|
13
20
|
:track_jobs,
|
|
@@ -35,11 +42,16 @@ module Profiler
|
|
|
35
42
|
@mcp_enabled = false
|
|
36
43
|
@mcp_transport = :stdio
|
|
37
44
|
@mcp_port = 3001
|
|
38
|
-
@authorization_mode = :
|
|
45
|
+
@authorization_mode = :allow_local
|
|
39
46
|
@authorize_block = nil
|
|
40
47
|
@max_profiles = 100
|
|
41
|
-
@extension_cors_enabled =
|
|
42
|
-
@cors_allowed_origins = [
|
|
48
|
+
@extension_cors_enabled = false
|
|
49
|
+
@cors_allowed_origins = []
|
|
50
|
+
@api_forgery_protection = true
|
|
51
|
+
# The Chrome extension shows the profiler in a DevTools panel: an iframe inside a
|
|
52
|
+
# chrome-extension:// page, itself inside the devtools://devtools front end. Chrome
|
|
53
|
+
# checks frame-ancestors against every ancestor, so both schemes are needed.
|
|
54
|
+
@frame_ancestors = ["'self'", "chrome-extension:", "devtools:"]
|
|
43
55
|
@track_ajax = true
|
|
44
56
|
@ajax_skip_paths = [/^\/_profiler/]
|
|
45
57
|
@track_http = true
|
|
@@ -87,6 +99,12 @@ module Profiler
|
|
|
87
99
|
case @authorization_mode
|
|
88
100
|
when :allow_all
|
|
89
101
|
true
|
|
102
|
+
when :allow_local
|
|
103
|
+
reason = LocalRequest.denial_reason(request)
|
|
104
|
+
return true if reason.nil?
|
|
105
|
+
|
|
106
|
+
LocalRequest.warn_once(reason)
|
|
107
|
+
false
|
|
90
108
|
when :allow_authorized
|
|
91
109
|
@authorize_block ? @authorize_block.call(request) : false
|
|
92
110
|
else
|
|
@@ -9,24 +9,39 @@ module Profiler
|
|
|
9
9
|
].freeze
|
|
10
10
|
|
|
11
11
|
def initialize(*args, &block)
|
|
12
|
+
# Ruby refuses a Thread with no block, and the wrapper below is a block, so
|
|
13
|
+
# without this the error would never come while a profile is being collected.
|
|
14
|
+
return super if block.nil?
|
|
15
|
+
|
|
12
16
|
parent_context = PROPAGATED_KEYS.filter_map do |key|
|
|
13
17
|
val = Thread.current[key]
|
|
14
18
|
[key, val] unless val.nil?
|
|
15
19
|
end.to_h
|
|
16
20
|
|
|
17
|
-
if parent_context.empty?
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
return super if parent_context.empty?
|
|
22
|
+
|
|
23
|
+
# Thread.new hands its arguments to the block, so the wrapper has to take
|
|
24
|
+
# them and pass them on. Ruby 3.4 depends on it in the stdlib: the Happy
|
|
25
|
+
# Eyeballs hostname resolution of Socket.tcp runs
|
|
26
|
+
# Thread.new(*thread_args) { |*thread_args| resolve_hostname(*thread_args) }
|
|
27
|
+
# and a wrapper that swallows the arguments leaves the name unresolved.
|
|
28
|
+
# The wrapper is a named proc rather than a literal block because only a
|
|
29
|
+
# proc can be given the ruby2_keywords flag, which, together with the one
|
|
30
|
+
# on this method, keeps a trailing keyword hash a keyword hash rather than
|
|
31
|
+
# flattening it into a positional argument.
|
|
32
|
+
wrapper = proc do |*block_args|
|
|
33
|
+
parent_context.each { |k, v| Thread.current[k] = v }
|
|
34
|
+
begin
|
|
35
|
+
block&.call(*block_args)
|
|
36
|
+
ensure
|
|
37
|
+
PROPAGATED_KEYS.each { |k| Thread.current[k] = nil }
|
|
27
38
|
end
|
|
28
39
|
end
|
|
40
|
+
wrapper.ruby2_keywords
|
|
41
|
+
|
|
42
|
+
super(*args, &wrapper)
|
|
29
43
|
end
|
|
44
|
+
ruby2_keywords :initialize
|
|
30
45
|
end
|
|
31
46
|
end
|
|
32
47
|
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ipaddr"
|
|
4
|
+
|
|
5
|
+
module Profiler
|
|
6
|
+
# Decides whether a request comes from this machine, for authorization_mode :allow_local.
|
|
7
|
+
#
|
|
8
|
+
# The client address is read from REMOTE_ADDR only. Forwarding headers (X-Forwarded-For,
|
|
9
|
+
# X-Real-IP, Forwarded, X-Forwarded-Host) can be forged, so they never grant access: they
|
|
10
|
+
# can only refuse it, when a local reverse proxy reports a remote client. The Host header
|
|
11
|
+
# has to be a local name, or one the application allows in config.hosts, which defeats DNS
|
|
12
|
+
# rebinding: a rebound page reaches 127.0.0.1 under the attacker's own domain name. The
|
|
13
|
+
# test environment also accepts the default hosts of rack-test and of Rails integration
|
|
14
|
+
# tests, reserved names (RFC 2606) that no attacker can point at a server of his own.
|
|
15
|
+
module LocalRequest
|
|
16
|
+
FORWARDING_HEADERS = {
|
|
17
|
+
"HTTP_X_FORWARDED_FOR" => "X-Forwarded-For",
|
|
18
|
+
"HTTP_X_REAL_IP" => "X-Real-IP",
|
|
19
|
+
"HTTP_FORWARDED" => "Forwarded"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
TEST_HOSTS = %w[www.example.com example.com example.org].freeze
|
|
23
|
+
|
|
24
|
+
@warned = false
|
|
25
|
+
@warn_mutex = Mutex.new
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
# Returns nil when the request is local, otherwise a sentence naming the cause.
|
|
29
|
+
def denial_reason(request)
|
|
30
|
+
remote_addr = request.get_header("REMOTE_ADDR").to_s
|
|
31
|
+
unless loopback_address?(remote_addr)
|
|
32
|
+
return "REMOTE_ADDR #{remote_addr.empty? ? "(missing)" : remote_addr} is not a loopback address"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
FORWARDING_HEADERS.each do |key, name|
|
|
36
|
+
value = request.get_header(key)
|
|
37
|
+
next if value.nil? || value.empty?
|
|
38
|
+
|
|
39
|
+
nodes = key == "HTTP_FORWARDED" ? forwarded_param(value, "for") : value.split(",")
|
|
40
|
+
remote = nodes.map { |node| strip_port(node) }.find { |address| !loopback_address?(address) }
|
|
41
|
+
return "the #{name} header reports a non-local client (#{remote})" if remote
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
host_denial_reason(request)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def local?(request)
|
|
48
|
+
denial_reason(request).nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Logs why a request was refused, once per process, so that an application running in
|
|
52
|
+
# Docker does not just stop being profiled with no explanation.
|
|
53
|
+
def warn_once(reason)
|
|
54
|
+
@warn_mutex.synchronize do
|
|
55
|
+
return if @warned
|
|
56
|
+
|
|
57
|
+
@warned = true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
message = "[Profiler] Request refused or not profiled by authorization_mode :allow_local: #{reason}. " \
|
|
61
|
+
"The profiler only serves and captures requests made from this machine. " \
|
|
62
|
+
"If the application runs in Docker or behind a remote proxy, either set " \
|
|
63
|
+
"config.authorization_mode = :allow_authorized with a config.authorize_with block " \
|
|
64
|
+
"that admits your network (see the Access control section of the README), or set " \
|
|
65
|
+
"config.authorization_mode = :allow_all, which offers no protection at all. " \
|
|
66
|
+
"This message is logged once per process."
|
|
67
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
68
|
+
Rails.logger.warn(message)
|
|
69
|
+
else
|
|
70
|
+
warn(message)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def reset_warning!
|
|
75
|
+
@warn_mutex.synchronize { @warned = false }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def loopback_address?(value)
|
|
79
|
+
address = value.to_s.strip
|
|
80
|
+
address = address[1..-2] if address.start_with?("[") && address.end_with?("]")
|
|
81
|
+
# Only an IPv6 address carries a zone identifier ("fe80::1%eth0").
|
|
82
|
+
address = address.sub(/%.*\z/, "") if address.include?(":")
|
|
83
|
+
return false if address.empty?
|
|
84
|
+
|
|
85
|
+
ip = IPAddr.new(address)
|
|
86
|
+
ip = ip.native if ip.ipv6? && ip.ipv4_mapped?
|
|
87
|
+
ip.loopback?
|
|
88
|
+
rescue IPAddr::Error
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def host_denial_reason(request)
|
|
95
|
+
# A request with no Host header does not come from a browser, so it cannot be a
|
|
96
|
+
# rebound page; REMOTE_ADDR alone decides.
|
|
97
|
+
raw_host = request.get_header("HTTP_HOST").to_s
|
|
98
|
+
unless raw_host.empty?
|
|
99
|
+
host = host_name(raw_host)
|
|
100
|
+
return "the Host header #{host.inspect} is not a local name" unless allowed_host?(host)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
forwarded_hosts = request.get_header("HTTP_X_FORWARDED_HOST").to_s.split(",")
|
|
104
|
+
remote = forwarded_hosts.map { |h| host_name(h) }.find { |h| !allowed_host?(h) }
|
|
105
|
+
return "the X-Forwarded-Host header #{remote.inspect} is not a local name" if remote
|
|
106
|
+
|
|
107
|
+
forwarded = request.get_header("HTTP_FORWARDED").to_s
|
|
108
|
+
remote = forwarded_param(forwarded, "host").map { |h| host_name(h) }.find { |h| !allowed_host?(h) }
|
|
109
|
+
return "the Forwarded header names a non-local host (#{remote.inspect})" if remote
|
|
110
|
+
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def test_environment?
|
|
115
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Values of one parameter (for=, host=) of a Forwarded header (RFC 7239), quotes removed.
|
|
119
|
+
def forwarded_param(value, param)
|
|
120
|
+
value.split(/[,;]/).filter_map do |pair|
|
|
121
|
+
name, node = pair.split("=", 2)
|
|
122
|
+
next unless name.to_s.strip.casecmp?(param)
|
|
123
|
+
|
|
124
|
+
node.to_s.strip.delete_prefix('"').delete_suffix('"')
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# "127.0.0.1:5555" -> "127.0.0.1", "[::1]:5555" -> "::1", "localhost:3000" -> "localhost".
|
|
129
|
+
# Any other form, a bare IPv6 address included, is returned as is: a malformed value is
|
|
130
|
+
# then refused unless it is local by itself.
|
|
131
|
+
def strip_port(value)
|
|
132
|
+
value = value.to_s.strip
|
|
133
|
+
match = value.match(/\A\[([^\]]+)\](?::\d+)?\z/) || value.match(/\A([^:]+)(?::\d+)?\z/)
|
|
134
|
+
match ? match[1] : value
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def host_name(value)
|
|
138
|
+
strip_port(value).downcase.chomp(".")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def allowed_host?(host)
|
|
142
|
+
return false if host.empty?
|
|
143
|
+
return true if host == "localhost" || host.end_with?(".localhost")
|
|
144
|
+
return true if loopback_address?(host)
|
|
145
|
+
return true if test_environment? && TEST_HOSTS.include?(host)
|
|
146
|
+
|
|
147
|
+
permitted_by_rails_hosts?(host)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# When the application restricts config.hosts, ActionDispatch::HostAuthorization has
|
|
151
|
+
# already refused every other host; a host it lists is one the developer trusts.
|
|
152
|
+
def permitted_by_rails_hosts?(host)
|
|
153
|
+
return false unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
154
|
+
return false unless defined?(ActionDispatch::HostAuthorization::Permissions)
|
|
155
|
+
|
|
156
|
+
hosts = Rails.application.config.hosts
|
|
157
|
+
return false if hosts.nil? || hosts.empty?
|
|
158
|
+
|
|
159
|
+
ActionDispatch::HostAuthorization::Permissions.new(hosts).allows?(host)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -2,60 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
module Middleware
|
|
5
|
+
# Sets the framing headers of every profiler response, and its CORS headers when
|
|
6
|
+
# extension_cors_enabled is set. Both settings are read on each request.
|
|
5
7
|
class CorsMiddleware
|
|
6
8
|
def initialize(app)
|
|
7
9
|
@app = app
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
def call(env)
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
['']
|
|
19
|
-
]
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
status, headers, body = @app.call(env)
|
|
23
|
-
headers = headers.dup
|
|
24
|
-
|
|
25
|
-
# Add CORS headers
|
|
26
|
-
cors_headers(env).each do |key, value|
|
|
27
|
-
headers[key] = value
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Allow embedding in iframes from Chrome extensions
|
|
31
|
-
headers.delete('X-Frame-Options')
|
|
32
|
-
# Don't set CSP if controller requested to skip it
|
|
33
|
-
unless env['profiler.skip_csp']
|
|
34
|
-
# Allow embedding from standard web origins
|
|
35
|
-
headers['Content-Security-Policy'] = "frame-ancestors 'self' http: https:"
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
[status, headers, body]
|
|
39
|
-
else
|
|
40
|
-
@app.call(env)
|
|
13
|
+
return @app.call(env) unless profiler_path?(env['PATH_INFO'].to_s)
|
|
14
|
+
|
|
15
|
+
cors = Profiler.configuration.extension_cors_enabled
|
|
16
|
+
|
|
17
|
+
# Handle OPTIONS preflight request
|
|
18
|
+
if cors && env['REQUEST_METHOD'] == 'OPTIONS'
|
|
19
|
+
return [200, cors_headers(env), ['']]
|
|
41
20
|
end
|
|
21
|
+
|
|
22
|
+
status, headers, body = @app.call(env)
|
|
23
|
+
headers = headers.dup
|
|
24
|
+
|
|
25
|
+
cors_headers(env).each { |key, value| set_header(headers, key, value) } if cors
|
|
26
|
+
|
|
27
|
+
# frame-ancestors decides in every browser that knows it, which then ignores
|
|
28
|
+
# X-Frame-Options; SAMEORIGIN only protects older browsers.
|
|
29
|
+
set_header(headers, 'X-Frame-Options', 'SAMEORIGIN')
|
|
30
|
+
set_header(headers, 'Content-Security-Policy', "frame-ancestors #{frame_ancestors}")
|
|
31
|
+
|
|
32
|
+
[status, headers, body]
|
|
42
33
|
end
|
|
43
34
|
|
|
44
35
|
private
|
|
45
36
|
|
|
37
|
+
# "/_profiler" itself is the dashboard URL; "/_profilerfoo" belongs to the application.
|
|
38
|
+
def profiler_path?(path)
|
|
39
|
+
path == '/_profiler' || path.start_with?('/_profiler/')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def frame_ancestors
|
|
43
|
+
sources = Array(Profiler.configuration.frame_ancestors).map(&:to_s).reject(&:empty?)
|
|
44
|
+
sources.empty? ? "'none'" : sources.join(' ')
|
|
45
|
+
end
|
|
46
|
+
|
|
46
47
|
def cors_headers(env)
|
|
47
|
-
allowed_origins = Profiler.configuration.cors_allowed_origins
|
|
48
|
+
allowed_origins = Array(Profiler.configuration.cors_allowed_origins)
|
|
48
49
|
request_origin = env['HTTP_ORIGIN']
|
|
49
50
|
|
|
50
|
-
if allowed_origins.include?(
|
|
51
|
-
origin_header = '*'
|
|
52
|
-
elsif request_origin && allowed_origins.include?(request_origin)
|
|
51
|
+
if request_origin && allowed_origins.include?(request_origin)
|
|
53
52
|
origin_header = request_origin
|
|
53
|
+
elsif allowed_origins.include?('*') && !credentialed?(env)
|
|
54
|
+
# Never on a request carrying credentials: they may be what authorizes it.
|
|
55
|
+
origin_header = '*'
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
headers = {
|
|
57
59
|
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
|
|
58
|
-
'Access-Control-Allow-Headers' =>
|
|
60
|
+
'Access-Control-Allow-Headers' => "Content-Type, X-Requested-With, Accept, #{Profiler::FORGERY_PROTECTION_HEADER}",
|
|
59
61
|
'Access-Control-Expose-Headers' => 'X-Profiler-Token'
|
|
60
62
|
}
|
|
61
63
|
|
|
@@ -66,6 +68,16 @@ module Profiler
|
|
|
66
68
|
|
|
67
69
|
headers
|
|
68
70
|
end
|
|
71
|
+
|
|
72
|
+
def credentialed?(env)
|
|
73
|
+
!env['HTTP_COOKIE'].to_s.empty? || !env['HTTP_AUTHORIZATION'].to_s.empty?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Replaces a header whatever the case of its existing key, plain Hash or Rack::Headers.
|
|
77
|
+
def set_header(headers, name, value)
|
|
78
|
+
headers.keys.each { |key| headers.delete(key) if key.to_s.casecmp?(name) }
|
|
79
|
+
headers[name] = value
|
|
80
|
+
end
|
|
69
81
|
end
|
|
70
82
|
end
|
|
71
83
|
end
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -24,11 +24,10 @@ module Profiler
|
|
|
24
24
|
if Profiler.configuration.enabled
|
|
25
25
|
require_relative "middleware/profiler_middleware"
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
end
|
|
27
|
+
# Always inserted: it sets the framing headers of every profiler response, and its
|
|
28
|
+
# CORS headers only when extension_cors_enabled is set.
|
|
29
|
+
require_relative "middleware/cors_middleware"
|
|
30
|
+
app.middleware.insert_before 0, Profiler::Middleware::CorsMiddleware
|
|
32
31
|
|
|
33
32
|
app.middleware.insert_before 0, Profiler::Middleware::ProfilerMiddleware
|
|
34
33
|
end
|
data/lib/profiler/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-profiler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.30.
|
|
4
|
+
version: 0.30.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sébastien Duplessy
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -103,6 +103,7 @@ executables:
|
|
|
103
103
|
extensions: []
|
|
104
104
|
extra_rdoc_files: []
|
|
105
105
|
files:
|
|
106
|
+
- CHANGELOG.md
|
|
106
107
|
- app/assets/builds/profiler-toolbar.js
|
|
107
108
|
- app/assets/builds/profiler.css
|
|
108
109
|
- app/assets/builds/profiler.js
|
|
@@ -167,6 +168,7 @@ files:
|
|
|
167
168
|
- lib/profiler/instrumentation/sidekiq_middleware.rb
|
|
168
169
|
- lib/profiler/instrumentation/thread_context_propagation.rb
|
|
169
170
|
- lib/profiler/job_profiler.rb
|
|
171
|
+
- lib/profiler/local_request.rb
|
|
170
172
|
- lib/profiler/mcp/body_formatter.rb
|
|
171
173
|
- lib/profiler/mcp/file_cache.rb
|
|
172
174
|
- lib/profiler/mcp/path_extractor.rb
|
|
@@ -231,6 +233,7 @@ licenses:
|
|
|
231
233
|
metadata:
|
|
232
234
|
homepage_uri: https://git.duplessy.eu/sebastien/rails-profiler-gem
|
|
233
235
|
source_code_uri: https://git.duplessy.eu/sebastien/rails-profiler-gem
|
|
236
|
+
changelog_uri: https://git.duplessy.eu/sebastien/rails-profiler-gem/-/blob/master/CHANGELOG.md
|
|
234
237
|
post_install_message:
|
|
235
238
|
rdoc_options: []
|
|
236
239
|
require_paths:
|