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.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +445 -0
  3. data/app/assets/builds/profiler-toolbar.js +2 -1
  4. data/app/assets/builds/profiler.js +2 -1
  5. data/app/controllers/profiler/api/ajax_controller.rb +0 -2
  6. data/app/controllers/profiler/api/cluster_controller.rb +0 -2
  7. data/app/controllers/profiler/api/console_controller.rb +0 -2
  8. data/app/controllers/profiler/api/env_vars_controller.rb +0 -2
  9. data/app/controllers/profiler/api/events_controller.rb +0 -1
  10. data/app/controllers/profiler/api/explain_controller.rb +0 -2
  11. data/app/controllers/profiler/api/function_profiling_controller.rb +0 -2
  12. data/app/controllers/profiler/api/jobs_controller.rb +0 -2
  13. data/app/controllers/profiler/api/outbound_http_controller.rb +0 -2
  14. data/app/controllers/profiler/api/profiles_controller.rb +0 -2
  15. data/app/controllers/profiler/api/slave_proxy_controller.rb +0 -2
  16. data/app/controllers/profiler/api/test_runner_controller.rb +0 -2
  17. data/app/controllers/profiler/api/tests_controller.rb +0 -2
  18. data/app/controllers/profiler/api/toolbar_controller.rb +0 -1
  19. data/app/controllers/profiler/application_controller.rb +33 -2
  20. data/app/controllers/profiler/assets_controller.rb +3 -0
  21. data/app/controllers/profiler/profiles_controller.rb +0 -9
  22. data/lib/profiler/cluster/master_client.rb +4 -2
  23. data/lib/profiler/cluster/slave_proxy.rb +1 -0
  24. data/lib/profiler/configuration.rb +22 -4
  25. data/lib/profiler/instrumentation/thread_context_propagation.rb +25 -10
  26. data/lib/profiler/local_request.rb +163 -0
  27. data/lib/profiler/middleware/cors_middleware.rb +47 -35
  28. data/lib/profiler/railtie.rb +4 -5
  29. data/lib/profiler/version.rb +1 -1
  30. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2940fc44efea04a648f7bba562f2fc19be1639d996525e9d08483ef7093baa16
4
- data.tar.gz: 1b9f6534d20625950aef62f906319942d7327ea609649aef00b70ba88c9b17ce
3
+ metadata.gz: e7a2ea61f4463670763c8c3c2d92f98a1f4ff2917fb9a2f5e0d245ea1860d2f1
4
+ data.tar.gz: ba467072b67f027565a29c99f458cd959933de512cdfa054ddde052d3e65ddb1
5
5
  SHA512:
6
- metadata.gz: 6ce1584ab5b3d70a6262d205cfbd0e4876d29e89b3766efde13fe345966734c20f6a73df1fe1e14b71f7b8af5c588b04e02c7e9a8b16a0f00c902ff8f5a55aa6
7
- data.tar.gz: 6e02c794a279605d81ac11c5f31f099378af2d82c967e757bf571ce2ff27ec7ca3f7c342f7e90cca628c5a406c253b0f0252971a723d0c4970db86ec35bd5c4e
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", ...headers } : headers,
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", ...headers } : headers,
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 {};
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class AjaxController < ApplicationController
6
- skip_before_action :verify_authenticity_token, only: [:link]
7
-
8
6
  def link
9
7
  parent_token = params[:parent_token]
10
8
  child_token = params[:child_token]
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ClusterController < Profiler::ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def register
9
7
  name = params[:name].to_s
10
8
  url = params[:url].to_s
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ConsoleController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def index
9
7
  limit = (params[:limit] || 50).to_i
10
8
  offset = (params[:offset] || 0).to_i
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class EnvVarsController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def show
9
7
  variables = ENV.to_h.sort.to_h
10
8
  overrides = Profiler.env_override_store.all_overrides
@@ -4,7 +4,6 @@ module Profiler
4
4
  module Api
5
5
  class EventsController < Profiler::ApplicationController
6
6
  include ActionController::Live
7
- skip_before_action :verify_authenticity_token
8
7
 
9
8
  def subscribe
10
9
  response.headers["Content-Type"] = "text/event-stream"
@@ -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
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class FunctionProfilingController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def show
9
7
  render json: {
10
8
  enabled: Profiler.function_profiling_enabled,
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class JobsController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def index
9
7
  limit = (params[:limit] || 50).to_i
10
8
  offset = (params[:offset] || 0).to_i
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class OutboundHttpController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  # GET /_profiler/api/outbound_http
9
7
  def index
10
8
  limit = (params[:limit] || 200).to_i
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ProfilesController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def index
9
7
  limit = (params[:limit] || 50).to_i
10
8
  offset = (params[:offset] || 0).to_i
@@ -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)
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class TestsController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def index
9
7
  limit = (params[:limit] || 50).to_i
10
8
  offset = (params[:offset] || 0).to_i
@@ -3,7 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ToolbarController < Profiler::ApplicationController
6
- skip_before_action :verify_authenticity_token
7
6
  skip_before_action :check_authorization
8
7
 
9
8
  def show
@@ -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
 
@@ -86,6 +86,7 @@ module Profiler
86
86
  end
87
87
 
88
88
  def request(uri, req)
89
+ req[Profiler::FORGERY_PROTECTION_HEADER] = "1"
89
90
  resp = Net::HTTP.start(uri.hostname, uri.port,
90
91
  open_timeout: @open_timeout, read_timeout: @read_timeout,
91
92
  use_ssl: uri.scheme == "https") do |http|
@@ -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 = :allow_all
45
+ @authorization_mode = :allow_local
39
46
  @authorize_block = nil
40
47
  @max_profiles = 100
41
- @extension_cors_enabled = true
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
- super
19
- else
20
- super(*args) do
21
- parent_context.each { |k, v| Thread.current[k] = v }
22
- begin
23
- block&.call
24
- ensure
25
- PROPAGATED_KEYS.each { |k| Thread.current[k] = nil }
26
- end
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
- # Apply CORS and frame options to all profiler endpoints
12
- if env['PATH_INFO'].start_with?('/_profiler/')
13
- # Handle OPTIONS preflight request
14
- if env['REQUEST_METHOD'] == 'OPTIONS'
15
- return [
16
- 200,
17
- cors_headers(env),
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' => 'Content-Type, X-Requested-With, Accept',
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
@@ -24,11 +24,10 @@ module Profiler
24
24
  if Profiler.configuration.enabled
25
25
  require_relative "middleware/profiler_middleware"
26
26
 
27
- # Insert CORS middleware first if enabled
28
- if Profiler.configuration.extension_cors_enabled
29
- require_relative "middleware/cors_middleware"
30
- app.middleware.insert_before 0, Profiler::Middleware::CorsMiddleware
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.4"
4
+ VERSION = "0.30.6"
5
5
  end
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
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-07-01 00:00:00.000000000 Z
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: