rails-profiler 0.30.5 → 0.30.7

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/app/assets/builds/profiler-toolbar.js +2 -1
  4. data/app/assets/builds/profiler.js +17 -3
  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 +22 -7
  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/collectors/database_collector.rb +5 -1
  25. data/lib/profiler/collectors/env_collector.rb +1 -1
  26. data/lib/profiler/collectors/mailer_collector.rb +13 -1
  27. data/lib/profiler/collectors/request_collector.rb +1 -1
  28. data/lib/profiler/configuration.rb +42 -4
  29. data/lib/profiler/explain_runner.rb +4 -0
  30. data/lib/profiler/instrumentation/net_http_instrumentation.rb +6 -4
  31. data/lib/profiler/job_profiler.rb +2 -1
  32. data/lib/profiler/local_request.rb +163 -0
  33. data/lib/profiler/mcp/tools/list_env_vars.rb +4 -3
  34. data/lib/profiler/mcp/tools/reset_env_var.rb +2 -1
  35. data/lib/profiler/mcp/tools/set_env_var.rb +5 -0
  36. data/lib/profiler/middleware/cors_middleware.rb +47 -35
  37. data/lib/profiler/models/profile.rb +9 -3
  38. data/lib/profiler/railtie.rb +4 -5
  39. data/lib/profiler/redaction.rb +421 -0
  40. data/lib/profiler/version.rb +1 -1
  41. data/lib/profiler.rb +1 -0
  42. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04defc47e71c610492ff23fbce13ebf669f459a1cac74c5933200bdf4d52365c
4
- data.tar.gz: d99cf7c5531fd369aa8c9367d95b0a5314f71f38de6ed64a7c81a9693f69566a
3
+ metadata.gz: f606ebbea7d5aecac4c62d789f07ddab9b4942269d4f8246441b3f37bc3220e7
4
+ data.tar.gz: 53f89719e215f799ba93d436fbace90ee45b38b0ec8d7f453cd2d2410e50cc0e
5
5
  SHA512:
6
- metadata.gz: '09ba67258286ddc275d2d1cc36234dd8f0e7d24783034dd3959c666b41d96bb334d5c38d720ceca2254d205449094aa57cac9de6afb1842e3437fbd2d238eac3'
7
- data.tar.gz: '0936d7ca150be6a80809739a2d9b94f77c51c80fad8796bb01d93fb2ca0047a5da3eb4ae832f9567c381bd3dcaacad3000b0273664362570306060c8b3b3eeec'
6
+ metadata.gz: 3fe0149c4be61248f1fbfdfa381083a01710248c81a8e02dfec015bd721e7ff0238c2296dbcb4df141701603f8f1a3b03cabd4ef839c3031006215680e34bed9
7
+ data.tar.gz: 1aca6f23e91b25b303db9d68f970f4878a222bb02cb5d1e5b89533f07c99306d38bec2f004b8471d8790bb6990b8a59a2a68a86cc00e50becb23b85020f8bdae
data/CHANGELOG.md CHANGED
@@ -19,6 +19,86 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.7] - 2026-10-04
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Security
27
+
28
+ - **Sensitive data:** Mask sensitive values with `[FILTERED]` before a profile is stored. Until
29
+ now `Authorization`, `Cookie` and `Set-Cookie` headers, raw request and response bodies
30
+ (passwords included), outbound request headers and bodies, and the whole of `ENV` were stored in
31
+ clear, and params lost only four exact root keys. The profiler now builds one filter from your
32
+ `Rails.application.config.filter_parameters` plus its own `config.filter_parameters`, with Rails
33
+ semantics (nested keys, any case, regexps, procs), and applies it to params and route params,
34
+ JSON, NDJSON and form bodies, incoming, response and outbound headers (the query string of
35
+ `Referer`, `Location` and `Content-Location` included), outbound URLs, SQL binds of filtered
36
+ columns, job arguments and mailer arguments. `multipart` bodies, and JSON bodies that cannot be
37
+ parsed and in which a filter matches, are masked entirely; HTML and other unstructured bodies are
38
+ kept. A params key that matches the filter is now kept with a masked value instead of being
39
+ removed.
40
+ - **Sensitive data:** `ENV` values are shown only for the variables in `config.env_allowlist`, in
41
+ the Env tab, the `env_vars` endpoint and the `list_env_vars`, `reset_env_var` and `get_profile`
42
+ MCP tools alike. `[FILTERED]` is refused as a value by the `env_vars` endpoint (422), the
43
+ `set_env_var` MCP tool and the Env tab import, so re-importing an export cannot overwrite a
44
+ secret. EXPLAIN is refused, with a message, for a query whose bind values were masked.
45
+ - **Sensitive data:** Procs of `filter_parameters` run once per string value, with the original
46
+ params for a proc of three arguments, as in Rails; other objects, Active Record models included,
47
+ are neither copied nor passed to them. When the filter fails, the value is masked rather than
48
+ the error reaching the application, and the error class is logged once, without the value. A
49
+ JSON body in whose text no filter matches is not parsed.
50
+ - **Upgrade note:** To get the previous behaviour back, set `config.redact_sensitive_data = false`
51
+ (no masking) and `config.env_allowlist = :all` (every `ENV` value); `config.filter_parameters = []`
52
+ keeps masking but relies on your application's list alone. Profiles stored before the upgrade
53
+ are not rewritten and keep their values in clear until they rotate out: clear them after
54
+ upgrading with `bin/rails runner 'Profiler.storage.clear'` (every storage backend), the
55
+ `clear_profiles` MCP tool or the dashboard; the memory store is emptied by a restart. Update
56
+ every node of a cluster: a node still on an older version returns its data in clear to the
57
+ master.
58
+
59
+ ## [0.30.6] - 2026-10-03
60
+
61
+ <!-- stamped -->
62
+
63
+ ### Security
64
+
65
+ - **Access control:** Check `authorization_mode` on every page and endpoint of the profiler (UI,
66
+ API, server-sent events, test runner, toolbar), answering `403` otherwise. It used to decide only
67
+ which requests were captured: with `:allow_authorized` and a block that refused everybody, the API
68
+ still listed and deleted profiles and wrote `ENV`. The gem's static JS and CSS stay public.
69
+ - **Access control:** The new default `authorization_mode`, `:allow_local`, only lets in requests
70
+ made from this machine: a loopback `REMOTE_ADDR`, no forwarding header naming a remote client, and
71
+ a local `Host` (or one listed in `config.hosts`), against DNS rebinding; the test environment
72
+ also accepts the reserved hosts `www.example.com`, `example.com` and `example.org`, so the
73
+ application's request specs are still captured. It also decides
74
+ which requests are captured, and logs the reason for a refusal once per process. The previous
75
+ default, `:allow_all`, let anybody who could reach the application read and change everything.
76
+ - **Access control:** API requests that change something (including a form `POST` turned into
77
+ another verb by `_method`) must carry an `X-Profiler-Request` header or a CSRF token, so that a
78
+ page on another site can no longer trigger them, even when the application turns
79
+ `allow_forgery_protection` off. The dashboard, the toolbar and the cluster send the header.
80
+ - **Access control:** CORS is off by default (`extension_cors_enabled = false`,
81
+ `cors_allowed_origins = []`), and `Access-Control-Allow-Origin: *` is never sent to a request
82
+ carrying a cookie or an `Authorization` header. It used to be `*` for everybody.
83
+ - **Access control:** Profiler pages may only be framed by the profiler itself and by the Chrome
84
+ extension's DevTools panel (`frame-ancestors 'self' chrome-extension: devtools:`, configurable
85
+ with the new `frame_ancestors` option), and send `X-Frame-Options: SAMEORIGIN`. Any website could
86
+ frame them, and with `?embed=true` a profile page carried no framing protection at all.
87
+ - **Upgrading:** Nothing to do when you browse the profiler from the machine that runs Rails. Each
88
+ previous behavior can be restored in `config/initializers/profiler.rb`:
89
+ - Docker, a VM or a remote proxy: admit your network with `config.authorization_mode =
90
+ :allow_authorized` and an `authorize_with` block reading `REMOTE_ADDR` (example in the README), or
91
+ go back to `config.authorization_mode = :allow_all`, which offers no protection.
92
+ - A cluster whose master and slaves run on different machines: each side admits the other's address
93
+ the same way.
94
+ - Your own scripts calling the API: send `X-Profiler-Request: 1`, or set
95
+ `config.api_forgery_protection = false`.
96
+ - Cross-origin clients: `config.extension_cors_enabled = true` and
97
+ `config.cors_allowed_origins = ["https://your.origin"]`. `["*"]` restores the behavior before
98
+ 0.30.6 and reopens the profiler to every website you visit: under `:allow_local` or `:allow_all`,
99
+ any page open in your browser can read its data and change it.
100
+ - Framing by other sites: `config.frame_ancestors = ["'self'", "http:", "https:"]`.
101
+
22
102
  ## [0.30.5] - 2026-10-03
23
103
 
24
104
  <!-- stamped -->
@@ -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 {};
@@ -4498,6 +4499,7 @@
4498
4499
  }
4499
4500
 
4500
4501
  // app/assets/typescript/profiler/components/dashboard/tabs/EnvTab.tsx
4502
+ var FILTERED_MASK = "[FILTERED]";
4501
4503
  function detectType(value) {
4502
4504
  if (value === "") return { label: "empty", color: "var(--profiler-text-muted)" };
4503
4505
  if (/^(true|false|yes|no)$/i.test(value)) return { label: "bool", color: "#a78bfa" };
@@ -4652,8 +4654,13 @@
4652
4654
  URL.revokeObjectURL(url);
4653
4655
  showFlash("success", `Exported ${entries.length} variables`);
4654
4656
  };
4655
- const importPreview = T2(() => parseEnvFile(importContent), [importContent]);
4657
+ const importParsed = T2(() => parseEnvFile(importContent), [importContent]);
4658
+ const importPreview = T2(
4659
+ () => Object.fromEntries(Object.entries(importParsed).filter(([, v3]) => v3 !== FILTERED_MASK)),
4660
+ [importParsed]
4661
+ );
4656
4662
  const importCount = Object.keys(importPreview).length;
4663
+ const importMaskedCount = Object.keys(importParsed).length - importCount;
4657
4664
  const importEnv = async () => {
4658
4665
  const entries = Object.entries(importPreview);
4659
4666
  if (!entries.length) return;
@@ -4663,7 +4670,8 @@
4663
4670
  setVariables((prev) => ({ ...prev, ...importPreview }));
4664
4671
  setImportContent("");
4665
4672
  setShowImport(false);
4666
- showFlash("success", `Imported ${entries.length} variables`);
4673
+ const skipped = importMaskedCount > 0 ? `, skipped ${importMaskedCount} masked` : "";
4674
+ showFlash("success", `Imported ${entries.length} variables${skipped}`);
4667
4675
  } catch (e3) {
4668
4676
  showFlash("error", e3.message ?? "Import failed");
4669
4677
  } finally {
@@ -4932,6 +4940,12 @@
4932
4940
  importCount !== 1 ? "s" : "",
4933
4941
  " to import"
4934
4942
  ] }),
4943
+ importMaskedCount > 0 && /* @__PURE__ */ u3("span", { class: "profiler-text--xs profiler-text--muted", children: [
4944
+ importMaskedCount,
4945
+ " masked (",
4946
+ FILTERED_MASK,
4947
+ ") skipped"
4948
+ ] }),
4935
4949
  /* @__PURE__ */ u3(
4936
4950
  "button",
4937
4951
  {
@@ -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,11 +3,9 @@
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
- variables = ENV.to_h.sort.to_h
10
- overrides = Profiler.env_override_store.all_overrides
7
+ variables = Profiler::Redaction.env_snapshot
8
+ overrides = Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)
11
9
  render json: { variables: variables, total: variables.size, overrides: overrides }
12
10
  end
13
11
 
@@ -21,10 +19,17 @@ module Profiler
21
19
 
22
20
  value = params[:value]
23
21
 
22
+ if Profiler::Redaction.mask?(value)
23
+ render json: { error: "#{Profiler::Redaction::MASK} is the mask the profiler shows in place of a " \
24
+ "hidden value, not a value; #{key} was left unchanged" },
25
+ status: :unprocessable_entity
26
+ return
27
+ end
28
+
24
29
  if value.nil? || value.to_s.empty?
25
30
  Profiler.env_override_store.delete(key)
26
31
  ENV.delete(key)
27
- render json: { key: key, value: nil, deleted: true, override: Profiler.env_override_store.all_overrides[key] }
32
+ render json: { key: key, value: nil, deleted: true, override: redacted_override(key) }
28
33
  else
29
34
  current_original = Profiler.env_override_store.all_overrides.dig(key, "original")
30
35
  if current_original == value.to_s
@@ -33,7 +38,7 @@ module Profiler
33
38
  Profiler.env_override_store.set(key, value.to_s)
34
39
  end
35
40
  ENV[key] = value.to_s
36
- render json: { key: key, value: ENV[key], override: Profiler.env_override_store.all_overrides[key] }
41
+ render json: { key: key, value: redacted_value(key), override: redacted_override(key) }
37
42
  end
38
43
  end
39
44
 
@@ -46,13 +51,23 @@ module Profiler
46
51
  end
47
52
 
48
53
  Profiler.env_override_store.reset(key)
49
- render json: { key: key, value: ENV[key], reset: true }
54
+ render json: { key: key, value: redacted_value(key), reset: true }
50
55
  end
51
56
 
52
57
  def reset_all
53
58
  Profiler.env_override_store.reset_all
54
59
  render json: { reset: true }
55
60
  end
61
+
62
+ private
63
+
64
+ def redacted_value(key)
65
+ ENV[key].nil? ? nil : Profiler::Redaction.env_value(key, ENV[key])
66
+ end
67
+
68
+ def redacted_override(key)
69
+ Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)[key]
70
+ end
56
71
  end
57
72
  end
58
73
  end
@@ -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|
@@ -96,11 +96,15 @@ module Profiler
96
96
 
97
97
  binds.map do |bind|
98
98
  if bind.respond_to?(:value)
99
- bind.value
99
+ name = bind.name if bind.respond_to?(:name)
100
+ Profiler::Redaction.filter_named(name, bind.value)
100
101
  else
101
102
  bind
102
103
  end
103
104
  end
105
+ rescue StandardError
106
+ # Never let the profiler raise into the application's query.
107
+ binds.map { Profiler::Redaction::MASK }
104
108
  end
105
109
 
106
110
  def extract_backtrace
@@ -25,7 +25,7 @@ module Profiler
25
25
  end
26
26
 
27
27
  def collect
28
- variables = ENV.to_h.sort.to_h
28
+ variables = Profiler::Redaction.env_snapshot
29
29
  store_data({ variables: variables, total: variables.size })
30
30
  end
31
31
 
@@ -199,7 +199,19 @@ module Profiler
199
199
  klass = Object.const_get(mailer_class)
200
200
  params = klass.instance_method(action).parameters
201
201
  params.each_with_index.each_with_object({}) do |((_, name), i), h|
202
- h[name.to_s] = serialize_assign(args[i])
202
+ h[name.to_s] = if Profiler::Redaction.sensitive_key?(name)
203
+ Profiler::Redaction::MASK
204
+ else
205
+ value = args[i]
206
+ # A hash or array goes through the filter by its keys, anything
207
+ # else by the parameter name: never both, so a proc runs once.
208
+ filtered = if value.is_a?(Hash) || value.is_a?(Array)
209
+ Profiler::Redaction.filter_value(value)
210
+ else
211
+ Profiler::Redaction.filter_named(name, value)
212
+ end
213
+ serialize_assign(filtered)
214
+ end
203
215
  end
204
216
  rescue StandardError
205
217
  {}
@@ -91,7 +91,7 @@ module Profiler
91
91
  {
92
92
  route_name: route_name ? "#{route_name}_path" : nil,
93
93
  route_pattern: matched_route&.path&.spec&.to_s&.sub(/\(\.:format\)$/, ""),
94
- route_params: route_params,
94
+ route_params: Profiler::Redaction.filter_hash(route_params),
95
95
  controller_action: controller_action
96
96
  }
97
97
  rescue StandardError
@@ -1,13 +1,36 @@
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
12
+ # Added to the application's own config.filter_parameters: the list of the
13
+ # Rails 7.1 application template, without :email.
14
+ DEFAULT_FILTER_PARAMETERS = %i[
15
+ passw secret token _key crypt salt certificate otp ssn cvv cvc
16
+ ].freeze
17
+
18
+ # ENV variables whose values the profiler shows; every other one is listed
19
+ # with its value masked.
20
+ DEFAULT_ENV_ALLOWLIST = %w[
21
+ RAILS_ENV RACK_ENV NODE_ENV RAILS_LOG_LEVEL RAILS_LOG_TO_STDOUT RAILS_SERVE_STATIC_FILES
22
+ RAILS_MAX_THREADS RAILS_MIN_THREADS WEB_CONCURRENCY PORT PIDFILE
23
+ LANG LANGUAGE LC_ALL LC_CTYPE TZ HOME PWD PATH SHELL USER HOSTNAME TERM
24
+ RUBY_VERSION RUBYOPT RUBY_YJIT_ENABLE MALLOC_ARENA_MAX
25
+ BUNDLE_GEMFILE BUNDLE_PATH BUNDLE_WITHOUT GEM_HOME GEM_PATH BOOTSNAP_CACHE_DIR
26
+ ].freeze
27
+
5
28
  attr_accessor :enabled, :storage_options, :collectors,
6
29
  :skip_paths, :slow_query_threshold, :max_queries_warning,
7
30
  :track_memory, :memory_warning_threshold,
8
31
  :mcp_enabled, :mcp_transport, :mcp_port,
9
32
  :authorization_mode, :max_profiles, :extension_cors_enabled,
10
- :cors_allowed_origins,
33
+ :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
11
34
  :track_ajax, :ajax_skip_paths,
12
35
  :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
13
36
  :track_jobs,
@@ -15,6 +38,7 @@ module Profiler
15
38
  :track_tests,
16
39
  :track_mailers, :capture_mail_body, :sanitize_mailer_recipients, :mailer_skip_actions,
17
40
  :compress_bodies, :compress_body_threshold,
41
+ :redact_sensitive_data, :filter_parameters, :env_allowlist,
18
42
  :name, :master_url, :self_url,
19
43
  :cluster_heartbeat_interval, :cluster_offline_threshold
20
44
 
@@ -35,11 +59,16 @@ module Profiler
35
59
  @mcp_enabled = false
36
60
  @mcp_transport = :stdio
37
61
  @mcp_port = 3001
38
- @authorization_mode = :allow_all
62
+ @authorization_mode = :allow_local
39
63
  @authorize_block = nil
40
64
  @max_profiles = 100
41
- @extension_cors_enabled = true
42
- @cors_allowed_origins = ["*"]
65
+ @extension_cors_enabled = false
66
+ @cors_allowed_origins = []
67
+ @api_forgery_protection = true
68
+ # The Chrome extension shows the profiler in a DevTools panel: an iframe inside a
69
+ # chrome-extension:// page, itself inside the devtools://devtools front end. Chrome
70
+ # checks frame-ancestors against every ancestor, so both schemes are needed.
71
+ @frame_ancestors = ["'self'", "chrome-extension:", "devtools:"]
43
72
  @track_ajax = true
44
73
  @ajax_skip_paths = [/^\/_profiler/]
45
74
  @track_http = true
@@ -55,6 +84,9 @@ module Profiler
55
84
  @mailer_skip_actions = []
56
85
  @compress_bodies = true
57
86
  @compress_body_threshold = 10 * 1024 # 10 KB
87
+ @redact_sensitive_data = true
88
+ @filter_parameters = DEFAULT_FILTER_PARAMETERS.dup
89
+ @env_allowlist = DEFAULT_ENV_ALLOWLIST.dup
58
90
  @tmp_path = nil
59
91
  @name = nil
60
92
  @master_url = nil
@@ -87,6 +119,12 @@ module Profiler
87
119
  case @authorization_mode
88
120
  when :allow_all
89
121
  true
122
+ when :allow_local
123
+ reason = LocalRequest.denial_reason(request)
124
+ return true if reason.nil?
125
+
126
+ LocalRequest.warn_once(reason)
127
+ false
90
128
  when :allow_authorized
91
129
  @authorize_block ? @authorize_block.call(request) : false
92
130
  else
@@ -28,6 +28,10 @@ module Profiler
28
28
  query = queries[query_index]
29
29
  sql = query["sql"].to_s
30
30
  binds = Array(query["binds"])
31
+ if binds.include?(Redaction::MASK)
32
+ raise ArgumentError, "EXPLAIN refused for query #{query_index}: one of its bind values was " \
33
+ "filtered when captured (config.filter_parameters), so the query cannot be rebuilt"
34
+ end
31
35
 
32
36
  conn = ActiveRecord::Base.connection
33
37
  adapter = conn.adapter_name.downcase