rails-profiler 0.30.10 → 0.30.12

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/app/assets/builds/profiler.js +20 -65
  4. data/app/controllers/profiler/api/cluster_controller.rb +37 -1
  5. data/app/controllers/profiler/api/slave_proxy_controller.rb +4 -2
  6. data/app/controllers/profiler/api/toolbar_controller.rb +0 -2
  7. data/app/controllers/profiler/application_controller.rb +13 -4
  8. data/app/controllers/profiler/profiles_controller.rb +2 -0
  9. data/app/views/profiler/profiles/show.html.erb +3 -1
  10. data/config/routes.rb +15 -8
  11. data/lib/profiler/cluster/master_client.rb +24 -6
  12. data/lib/profiler/cluster/security.rb +179 -0
  13. data/lib/profiler/cluster/slave_proxy.rb +24 -1
  14. data/lib/profiler/collectors/base_collector.rb +2 -0
  15. data/lib/profiler/collectors/console_collector.rb +2 -2
  16. data/lib/profiler/collectors/flamegraph_collector.rb +2 -2
  17. data/lib/profiler/collectors/i18n_collector.rb +1 -1
  18. data/lib/profiler/collectors/mailer_collector.rb +5 -5
  19. data/lib/profiler/configuration.rb +21 -1
  20. data/lib/profiler/console_profiler.rb +1 -1
  21. data/lib/profiler/instrumentation/net_http_instrumentation.rb +2 -1
  22. data/lib/profiler/job_profiler.rb +2 -2
  23. data/lib/profiler/mcp/http_guard.rb +73 -0
  24. data/lib/profiler/mcp/server.rb +4 -1
  25. data/lib/profiler/middleware/toolbar_injector.rb +94 -6
  26. data/lib/profiler/models/profile.rb +14 -4
  27. data/lib/profiler/railtie.rb +5 -1
  28. data/lib/profiler/redaction.rb +98 -0
  29. data/lib/profiler/test_runner/run_store.rb +39 -2
  30. data/lib/profiler/test_runner/runner.rb +9 -3
  31. data/lib/profiler/version.rb +1 -1
  32. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d1f1618012f3a987e45ec2f18c565acc7cda82685806ca78801e6fb892012b3
4
- data.tar.gz: 0f8e433032ac46e974c6a4162afa9c44610f5fd424afdcaf7f12a601f983cfab
3
+ metadata.gz: fc826508539b9892ab3317d52a95d77cdb162b4439a4086ae3c538239edb7eaa
4
+ data.tar.gz: 05c364a0cfeca030f37e635b35b150819f81f266de13137f98d33b9a9be29a53
5
5
  SHA512:
6
- metadata.gz: 30be96f28f9d20d285b9105725cb0704ed97af7ea8e34d425f7bf3fb8b4ef2f739cded05ab68b1c2931a8424058c18245e72eddb797bbee15c8d2fc1754175e7
7
- data.tar.gz: 70021a953b7a36fdff2287d9e46242761e02ea114aa6ad16344860e04cb1973d6a87095d120216b4c60b48e9187130ca7a8461b0627c35a90f683931f3ac632c
6
+ metadata.gz: b0439a929e4b29fb9cc74ea0806135bfe10fb619bac6664c375aac1ae2b969f5adcd51a1f2d04322878c952d79430e6dec6350f4bda37665df26fe459b63ac0f
7
+ data.tar.gz: cedc00eb32c405fa556be7723fe2adde3ae7704c57e2053d6fcf8828c933deecb1b300833f7975fdd3239679c25512435c8ccf107e7d84925400f00ea661aa02
data/CHANGELOG.md CHANGED
@@ -19,6 +19,111 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.12] - 2026-10-04
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Security
27
+
28
+ - **Toolbar:** The toolbar endpoint (`/_profiler/api/toolbar/:token`) now answers `403` when the
29
+ profiler is disabled, like the rest of the API. It used to keep serving any profile left in
30
+ storage, request headers and environment included, after `enabled = false`. The toolbar is only
31
+ injected while the profiler is enabled, so it is not affected.
32
+ - **Profile page:** The profile embedded as JSON in the profile page is now escaped explicitly
33
+ (`<`, `>`, `&`, U+2028 and U+2029), whatever the application sets for
34
+ `ActiveSupport.escape_html_entities_in_json`. An application that turned that setting off let a
35
+ captured value (a parameter, a header, a body) close the `<script>` element and run JavaScript
36
+ in the application's origin. The token and the CSP nonce written into the application's pages
37
+ by the toolbar injector are escaped too.
38
+ - **Dashboard:** Captured bodies offered for download are no longer typed with their captured
39
+ content type: opened in a tab, a `text/html` or `image/svg+xml` body ran its scripts in the
40
+ application's origin. Previews keep their type for raster images and PDF only. Email previews
41
+ are rendered in a fully sandboxed iframe, with an opaque origin. The unused SQL highlighter,
42
+ which wrote captured SQL into the page as HTML, is removed.
43
+ - **Access control:** A disabled profiler now answers its API requests with a JSON error, like
44
+ any other refusal of the API, instead of plain text.
45
+ - **Toolbar:** The toolbar is now injected before the `</body>` that closes the page, looked for
46
+ outside comments, tags (attributes read as the browser reads them, quoted values included) and
47
+ the elements whose content is not markup (`<script>`, `<style>`, `<textarea>`, `<title>`,
48
+ `<xmp>`, `<iframe>`, `<noembed>`, `<noframes>`, `<noscript>`), instead of the first `</body>`
49
+ found. A `</body>` inside a script string of the page used to receive it, and the toolbar's own
50
+ `</script>` then turned the rest of that string into live markup. A page whose only `</body>`
51
+ sits in one of those, that leaves a comment, a tag, a quoted value or one of those elements
52
+ open, or that holds a `<plaintext>`, a double-escaped script or a CDATA section with a `>`
53
+ before its end, gets no toolbar.
54
+
55
+ ## [0.30.11] - 2026-10-04
56
+
57
+ <!-- stamped -->
58
+
59
+ ### Fixed
60
+
61
+ - **Cluster:** A profile page served by the master now finds profiles held by a slave even before
62
+ the slave proxy has been used once.
63
+
64
+ ### Security
65
+
66
+ - **MCP:** The HTTP transport at `/_profiler/mcp` is routed only when `mcp_enabled` is true and
67
+ `mcp_transport` is `:http`; it answers `404` otherwise. It used to be routed in every application,
68
+ whatever these options said, and to answer anyone: after the MCP handshake, any client could list
69
+ the tools and call those that write `ENV`, clear profiles or run the test suite. The stdio
70
+ transport (`rake profiler:mcp`) is unchanged.
71
+ - **MCP:** When it is routed, each request to `/_profiler/mcp` passes the profiler's own checks
72
+ before the MCP server sees it, and gets a `403` otherwise: the profiler must be enabled, the
73
+ request must pass `authorization_mode`, and a `POST` must carry `Content-Type: application/json`
74
+ with no foreign `Origin`, so that a page on another site cannot call the tools from your browser.
75
+ - **Cluster:** The cluster endpoints (`register`, `heartbeat`, `slaves` and the slave proxy) are
76
+ routed only on a node with the new `cluster_master = true`; they answer `404` otherwise. Every
77
+ application used to accept slave registrations.
78
+ - **Cluster:** The master and its slaves authenticate each other with the new shared
79
+ `cluster_secret`, sent in an `X-Profiler-Cluster-Secret` header and compared in constant time:
80
+ `register` and `heartbeat` refuse a missing or wrong secret, and everything while no secret is
81
+ configured. On a slave, the master's proxied calls are let in by the secret, so a master on
82
+ another machine no longer needs the slave to admit its address.
83
+ - **Cluster:** The master registers, and sends requests to, only the slave URLs allowed by the new
84
+ `cluster_allowed_slave_urls` (scheme, host and port, compared after normalization, and a path
85
+ prefix); the default refuses all. HTTPS is required, except for loopback addresses, for slave URLs
86
+ and for `master_url` (new `cluster_allow_insecure_http` to lift it), and the master no longer
87
+ returns the body of a redirect. Anyone who could register used to make the master fetch any host
88
+ and port it could reach and read the answer (server-side request forgery).
89
+ - **Cluster:** A path, profile token or MCP argument sent to a slave can no longer leave the slave's
90
+ `/_profiler/api/`: `?`, `#` and `%` are encoded into the path segment, and a `.`, `..` or `/`
91
+ segment is refused before any request, on the slave proxy, the profile pages and the MCP tools.
92
+ An encoded `..` or `?` used to reach other paths of the slave.
93
+ - **Cluster:** A `cluster_secret` shorter than 32 characters, or blank, is ignored as if none were
94
+ configured, with a warning at boot and an explicit error on registration. The master stores slave
95
+ URLs in their normalized form.
96
+ - **Cluster:** The `cluster_secret` is masked by value in everything a profile captures (params,
97
+ bodies, headers in and out, URLs, `ENV`, SQL binds, job and mailer arguments, and the free text of
98
+ every collector: log lines, exception messages, dumps, SQL text, console expressions), and in the
99
+ output of the test runner, whatever name it travels under and even with `redact_sensitive_data`
100
+ off, on top of the masking by name.
101
+ - **Cluster:** The `cluster_secret` is masked before text is shortened (flame graph names, I18n
102
+ values, console expressions and results, mail bodies and assigns, job arguments), so no prefix of
103
+ it is left, and the output of the test runner is masked across the pieces it is read in, so the
104
+ secret no longer comes back whole once they are joined. Only a secret the cluster accepts (32
105
+ characters or more) is masked by value.
106
+ - **Cluster:** The output of the test runner holds back an end that could start the secret until
107
+ the process has finished printing, a killed run included, and holds back nothing else, so
108
+ progress output is shown as it comes. Binary bodies stored in base64, incoming, response and
109
+ outbound, are masked on their raw bytes before they are encoded.
110
+ - **Upgrading:** Nothing to do if you use neither the MCP HTTP endpoint nor the cluster. Otherwise,
111
+ in `config/initializers/profiler.rb`:
112
+ - MCP over HTTP: set `config.mcp_enabled = true` and `config.mcp_transport = :http`. An
113
+ installation that used `/_profiler/mcp` while `mcp_transport` stayed at its default, `:stdio`,
114
+ must now set `:http`. The checks have no MCP-specific switch: `config.authorization_mode =
115
+ :allow_all` lets anybody who can reach the application call every tool, and
116
+ `config.api_forgery_protection = false` lets any website you visit make your browser call them.
117
+ - Cluster master: set `config.cluster_master = true`, `config.cluster_secret` (at least 32
118
+ characters, the same value on every node, from the environment) and
119
+ `config.cluster_allowed_slave_urls`. Slaves: set the same `config.cluster_secret`, and use HTTPS
120
+ URLs unless master and slave share the machine.
121
+ - To go back to the previous behavior: `config.cluster_require_secret = false` (with no
122
+ `cluster_secret`) accepts registrations from any client `authorization_mode` lets in, as in
123
+ 0.30.6; `config.cluster_allowed_slave_urls = :any` accepts any slave URL, which reopens the
124
+ server-side request forgery; `config.cluster_allow_insecure_http = true` sends the secret and the
125
+ profiles in clear to remote hosts.
126
+
22
127
  ## [0.30.10] - 2026-10-04
23
128
 
24
129
  <!-- stamped -->
@@ -4054,7 +4054,7 @@
4054
4054
  function DownloadTextButton({ text, mime }) {
4055
4055
  const [url, setUrl] = d2(null);
4056
4056
  y2(() => {
4057
- const objectUrl = URL.createObjectURL(new Blob([text], { type: mime }));
4057
+ const objectUrl = URL.createObjectURL(new Blob([text], { type: "application/octet-stream" }));
4058
4058
  setUrl(objectUrl);
4059
4059
  return () => URL.revokeObjectURL(objectUrl);
4060
4060
  }, [text, mime]);
@@ -4142,11 +4142,17 @@
4142
4142
  };
4143
4143
  return map[category] || "text/plain";
4144
4144
  }
4145
+ var INERT_PREVIEW_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif", "application/pdf"];
4146
+ function inertPreviewType(mime) {
4147
+ const m3 = mime.toLowerCase();
4148
+ return INERT_PREVIEW_TYPES.includes(m3) ? m3 : "application/octet-stream";
4149
+ }
4145
4150
  var PREVIEW_LIMIT = 500;
4146
4151
  var CSV_ROW_LIMIT = 10;
4147
4152
  function SmartBodyPreview({ body, encoding, headers }) {
4148
4153
  const [expanded, setExpanded] = d2(false);
4149
4154
  const [objectUrl, setObjectUrl] = d2(null);
4155
+ const [downloadUrl, setDownloadUrl] = d2(null);
4150
4156
  const category = detectContentType(headers);
4151
4157
  y2(() => {
4152
4158
  if (encoding !== "base64" || !body) return;
@@ -4154,19 +4160,27 @@
4154
4160
  const raw = atob(body);
4155
4161
  const bytes = new Uint8Array(raw.length);
4156
4162
  for (let i3 = 0; i3 < raw.length; i3++) bytes[i3] = raw.charCodeAt(i3);
4157
- const url = URL.createObjectURL(new Blob([bytes], { type: mime }));
4163
+ const url = URL.createObjectURL(new Blob([bytes], { type: inertPreviewType(mime) }));
4164
+ const download = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
4158
4165
  setObjectUrl(url);
4159
- return () => URL.revokeObjectURL(url);
4166
+ setDownloadUrl(download);
4167
+ return () => {
4168
+ URL.revokeObjectURL(url);
4169
+ URL.revokeObjectURL(download);
4170
+ };
4160
4171
  }, [body, encoding, headers]);
4161
4172
  if (!body) return /* @__PURE__ */ u3("span", { class: "profiler-text--muted profiler-text--xs", children: "empty" });
4162
4173
  if (encoding === "base64") {
4163
4174
  const mime = Object.entries(headers).find(([k3]) => k3.toLowerCase() === "content-type")?.[1]?.split(";")[0].trim() || "application/octet-stream";
4164
4175
  const filename = `body${mimeToExt(mime)}`;
4165
4176
  return /* @__PURE__ */ u3("div", { class: "profiler-body-binary", children: [
4166
- objectUrl && (category === "image" || category === "svg") && /* @__PURE__ */ u3("img", { src: objectUrl, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4177
+ objectUrl && category === "image" && /* @__PURE__ */ u3("img", { src: objectUrl, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4178
+ category === "svg" && // A data: URL rather than a blob: one: an <img> runs no script, and browsers refuse
4179
+ // to open a data: URL in a tab of its own.
4180
+ /* @__PURE__ */ u3("img", { src: `data:image/svg+xml;base64,${body}`, alt: "response preview", style: "max-width:100%;max-height:300px;display:block;margin-bottom:8px;border-radius:4px;border:1px solid var(--profiler-border)" }),
4167
4181
  objectUrl && category === "pdf" && /* @__PURE__ */ u3("iframe", { src: objectUrl, class: "profiler-body-preview-frame", title: "PDF preview" }),
4168
4182
  /* @__PURE__ */ u3("div", { style: "display:flex;gap:8px;margin-top:4px", children: [
4169
- objectUrl && /* @__PURE__ */ u3("a", { href: objectUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
4183
+ downloadUrl && /* @__PURE__ */ u3("a", { href: downloadUrl, download: filename, class: "profiler-body-download-btn profiler-text--xs", children: [
4170
4184
  "Download ",
4171
4185
  mime
4172
4186
  ] }),
@@ -8615,7 +8629,7 @@
8615
8629
  "iframe",
8616
8630
  {
8617
8631
  srcdoc: email.body_html ?? void 0,
8618
- sandbox: "allow-same-origin",
8632
+ sandbox: "",
8619
8633
  style: { width: "100%", height: "300px", border: "1px solid var(--profiler-border)", borderRadius: "var(--profiler-radius-md)", background: "#fff", display: "block" }
8620
8634
  }
8621
8635
  ),
@@ -9421,62 +9435,6 @@ Duration: ${event.duration.toFixed(2)}ms`;
9421
9435
  return "#dcdcaa";
9422
9436
  }
9423
9437
 
9424
- // app/assets/typescript/profiler/sql-formatter.ts
9425
- var SQL_KEYWORDS = [
9426
- "SELECT",
9427
- "FROM",
9428
- "WHERE",
9429
- "INSERT",
9430
- "UPDATE",
9431
- "DELETE",
9432
- "JOIN",
9433
- "LEFT",
9434
- "RIGHT",
9435
- "INNER",
9436
- "OUTER",
9437
- "ON",
9438
- "AS",
9439
- "AND",
9440
- "OR",
9441
- "NOT",
9442
- "IN",
9443
- "EXISTS",
9444
- "LIKE",
9445
- "ORDER",
9446
- "BY",
9447
- "GROUP",
9448
- "HAVING",
9449
- "LIMIT",
9450
- "OFFSET",
9451
- "DISTINCT",
9452
- "COUNT",
9453
- "SUM",
9454
- "AVG",
9455
- "MAX",
9456
- "MIN",
9457
- "CASE",
9458
- "WHEN",
9459
- "THEN",
9460
- "ELSE",
9461
- "END"
9462
- ];
9463
- function formatSQL(element) {
9464
- const sql = element.textContent || "";
9465
- const formatted = highlightSQL(sql);
9466
- element.innerHTML = formatted;
9467
- }
9468
- function highlightSQL(sql) {
9469
- let highlighted = sql;
9470
- SQL_KEYWORDS.forEach((keyword) => {
9471
- const regex = new RegExp(`\\b${keyword}\\b`, "gi");
9472
- highlighted = highlighted.replace(regex, `<span class="sql-keyword">${keyword}</span>`);
9473
- });
9474
- highlighted = highlighted.replace(/'([^']*)'/g, `<span class="sql-string">'$1'</span>`);
9475
- highlighted = highlighted.replace(/\b(\d+)\b/g, '<span class="sql-number">$1</span>');
9476
- highlighted = highlighted.replace(/--([^\n]*)/g, '<span class="sql-comment">--$1</span>');
9477
- return highlighted;
9478
- }
9479
-
9480
9438
  // app/assets/typescript/profiler/theme.ts
9481
9439
  var STORAGE_KEY2 = "profiler-theme";
9482
9440
  var THEME_ATTRIBUTE = "data-theme";
@@ -9663,8 +9621,5 @@ Duration: ${event.duration.toFixed(2)}ms`;
9663
9621
  if (timeline) {
9664
9622
  initTimeline(timeline);
9665
9623
  }
9666
- document.querySelectorAll('pre[data-language="sql"]').forEach((block) => {
9667
- formatSQL(block);
9668
- });
9669
9624
  });
9670
9625
  })();
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../../../lib/profiler/cluster/security"
4
+
3
5
  module Profiler
4
6
  module Api
5
7
  class ClusterController < Profiler::ApplicationController
8
+ SLAVE_ACTIONS = %w[register heartbeat].freeze
9
+
6
10
  def register
7
11
  name = params[:name].to_s
8
12
  url = params[:url].to_s
@@ -11,7 +15,11 @@ module Profiler
11
15
  return render json: { error: "name and url are required" }, status: :unprocessable_entity
12
16
  end
13
17
 
14
- Profiler.slave_registry.register(name: name, url: url)
18
+ if (reason = Cluster::Security.slave_url_denial(url))
19
+ return render json: { error: reason }, status: :unprocessable_entity
20
+ end
21
+
22
+ Profiler.slave_registry.register(name: name, url: Cluster::Security.normalized_url(url))
15
23
  render json: { ok: true, name: name }
16
24
  end
17
25
 
@@ -28,6 +36,34 @@ module Profiler
28
36
  def slaves
29
37
  render json: { slaves: Profiler.slave_registry.all }
30
38
  end
39
+
40
+ private
41
+
42
+ # A slave is a server, often on another host, not a browser: it proves itself with the
43
+ # shared secret instead of the access guard and the forgery header. Without
44
+ # cluster_require_secret, and with no secret configured, the guard of the other
45
+ # endpoints applies, as in 0.30.6.
46
+ def authorize_request
47
+ return super unless secret_authenticated_action?
48
+ return if Cluster::Security.request_secret_valid?(request)
49
+
50
+ if (problem = Cluster::Security.secret_problem)
51
+ deny("#{problem} on this master: cluster requests are refused")
52
+ else
53
+ deny("Missing or invalid #{Cluster::Security::SECRET_HEADER} header")
54
+ end
55
+ end
56
+
57
+ # The secret is a custom header, which a cross-site page cannot send without a preflight.
58
+ def verified_request?
59
+ return Cluster::Security.request_secret_valid?(request) if secret_authenticated_action?
60
+
61
+ super
62
+ end
63
+
64
+ def secret_authenticated_action?
65
+ SLAVE_ACTIONS.include?(action_name) && Cluster::Security.secret_required?
66
+ end
31
67
  end
32
68
  end
33
69
  end
@@ -7,8 +7,10 @@ module Profiler
7
7
  class SlaveProxyController < Profiler::ApplicationController
8
8
  def forward
9
9
  proxy = Cluster::SlaveProxy.new(params[:slave_name])
10
- sub_path = params[:path].to_s
11
- full_path = "/_profiler/api/#{sub_path}"
10
+ # Each segment is escaped: a decoded "?" or "#" stays in its segment, and a dot segment
11
+ # is refused, so the request stays under the slave's API.
12
+ segments = params[:path].to_s.split("/", -1).map { |segment| Cluster::Security.escape_segment(segment) }
13
+ full_path = "/_profiler/api/#{segments.join("/")}"
12
14
 
13
15
  result = case request.method
14
16
  when "GET"
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ToolbarController < Profiler::ApplicationController
6
- skip_before_action :check_authorization
7
-
8
6
  def show
9
7
  profile = Profiler.storage.load(params[:token])
10
8
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../../lib/profiler/cluster/security"
4
+
3
5
  module Profiler
4
6
  class ApplicationController < ActionController::Base
5
7
  layout "profiler/application"
@@ -12,12 +14,12 @@ module Profiler
12
14
  private
13
15
 
14
16
  def check_authorization
15
- unless Profiler.configuration.enabled
16
- render plain: "Profiler is disabled", status: :forbidden
17
- end
17
+ deny("Profiler is disabled") unless Profiler.configuration.enabled
18
18
  end
19
19
 
20
20
  def authorize_request
21
+ # Checked first, so that :allow_local does not log a refusal for the master's calls.
22
+ return if cluster_master_request?
21
23
  return if Profiler.configuration.authorized?(request)
22
24
 
23
25
  deny("Not authorized to access the profiler")
@@ -32,7 +34,14 @@ module Profiler
32
34
  # Rails' token, or the header every profiler client sends. A third-party page cannot add a
33
35
  # custom header without a CORS preflight, which is refused unless an origin was allowed.
34
36
  def verified_request?
35
- super || request.headers[Profiler::FORGERY_PROTECTION_HEADER].present?
37
+ super || request.headers[Profiler::FORGERY_PROTECTION_HEADER].present? || cluster_master_request?
38
+ end
39
+
40
+ # On a slave, the master's proxied calls carry the shared secret, which stands for both the
41
+ # access guard and the forgery header. Never true on a node without master_url, or without
42
+ # a configured secret.
43
+ def cluster_master_request?
44
+ Profiler.configuration.slave? && Cluster::Security.request_secret_valid?(request)
36
45
  end
37
46
 
38
47
  def handle_unverified_request
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../../../lib/profiler/cluster/slave_proxy"
4
+
3
5
  module Profiler
4
6
  class ProfilesController < ApplicationController
5
7
  def index
@@ -1,4 +1,6 @@
1
1
  <div id="profiler-show" data-embedded="<%= @embedded %>"></div>
2
+ <%# json_escape turns <, >, & and U+2028/U+2029 into \u escapes, so that no captured value can
3
+ close the element, whatever the application sets for escape_html_entities_in_json. %>
2
4
  <script type="application/json" id="profiler-show-data">
3
- <%= @profile_data.to_json.html_safe %>
5
+ <%= raw json_escape(@profile_data.to_json) %>
4
6
  </script>
data/config/routes.rb CHANGED
@@ -3,7 +3,12 @@
3
3
  require "profiler/mcp/server"
4
4
 
5
5
  Profiler::Engine.routes.draw do
6
- mount Profiler::MCP::Server.rack_app, at: "mcp"
6
+ # Read on each request rather than when the routes are drawn: the application's initializer
7
+ # may set these options after the engine routes are loaded. A route that does not match
8
+ # answers 404, as if it were not there.
9
+ constraints(->(_request) { Profiler.configuration.mcp_http_enabled? }) do
10
+ mount Profiler::MCP::Server.rack_app, at: "mcp"
11
+ end
7
12
 
8
13
  root to: "profiles#index"
9
14
 
@@ -52,14 +57,16 @@ Profiler::Engine.routes.draw do
52
57
  delete "test_runner/runs/:id", to: "test_runner#destroy"
53
58
  get "events/:token", to: "events#subscribe", as: :profile_events
54
59
 
55
- # Cluster endpoints (master-side)
56
- post "cluster/register", to: "cluster#register"
57
- post "cluster/heartbeat", to: "cluster#heartbeat"
58
- get "cluster/slaves", to: "cluster#slaves"
60
+ # Cluster endpoints (master-side), routed only on a node with cluster_master set
61
+ constraints(->(_request) { Profiler.configuration.cluster_master? }) do
62
+ post "cluster/register", to: "cluster#register"
63
+ post "cluster/heartbeat", to: "cluster#heartbeat"
64
+ get "cluster/slaves", to: "cluster#slaves"
59
65
 
60
- # Slave proxy — must be last to avoid shadowing other api routes
61
- scope "/slaves/:slave_name" do
62
- match "*path", to: "slave_proxy#forward", via: :all
66
+ # Slave proxy: must be last to avoid shadowing other api routes
67
+ scope "/slaves/:slave_name" do
68
+ match "*path", to: "slave_proxy#forward", via: :all
69
+ end
63
70
  end
64
71
  end
65
72
  end
@@ -3,6 +3,7 @@
3
3
  require "net/http"
4
4
  require "json"
5
5
  require "uri"
6
+ require_relative "security"
6
7
 
7
8
  module Profiler
8
9
  module Cluster
@@ -22,10 +23,9 @@ module Profiler
22
23
 
23
24
  def register!
24
25
  config = Profiler.configuration
25
- uri = URI("#{config.master_url}/_profiler/api/cluster/register")
26
+ uri = master_uri("register")
26
27
  body = { name: config.resolved_name, url: config.self_url }.to_json
27
- resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json",
28
- Profiler::FORGERY_PROTECTION_HEADER => "1")
28
+ resp = Net::HTTP.post(uri, body, request_headers)
29
29
  unless resp.code.to_i.between?(200, 299)
30
30
  raise "Master returned #{resp.code}: #{resp.body.to_s.slice(0, 200)}"
31
31
  end
@@ -35,13 +35,31 @@ module Profiler
35
35
 
36
36
  def heartbeat!
37
37
  config = Profiler.configuration
38
- uri = URI("#{config.master_url}/_profiler/api/cluster/heartbeat")
38
+ uri = master_uri("heartbeat")
39
39
  body = { name: config.resolved_name }.to_json
40
- resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json",
41
- Profiler::FORGERY_PROTECTION_HEADER => "1")
40
+ resp = Net::HTTP.post(uri, body, request_headers)
42
41
  raise "Heartbeat rejected #{resp.code}" unless resp.code.to_i.between?(200, 299)
43
42
  end
44
43
 
44
+ # Refused before any request when the secret would cross the network in clear, or when
45
+ # there is no secret for the master to accept.
46
+ def master_uri(action)
47
+ config = Profiler.configuration
48
+ if (reason = Security.master_url_denial(config.master_url))
49
+ raise reason
50
+ end
51
+ if Security.secret_required? && (problem = Security.secret_problem)
52
+ raise "#{problem}: the master refuses registration without a secret"
53
+ end
54
+
55
+ URI("#{config.master_url}/_profiler/api/cluster/#{action}")
56
+ end
57
+
58
+ def request_headers
59
+ { "Content-Type" => "application/json", Profiler::FORGERY_PROTECTION_HEADER => "1" }
60
+ .merge(Security.outgoing_headers)
61
+ end
62
+
45
63
  def start_heartbeat_thread
46
64
  Thread.new do
47
65
  interval = Profiler.configuration.cluster_heartbeat_interval