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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +105 -0
- data/app/assets/builds/profiler.js +20 -65
- data/app/controllers/profiler/api/cluster_controller.rb +37 -1
- data/app/controllers/profiler/api/slave_proxy_controller.rb +4 -2
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -2
- data/app/controllers/profiler/application_controller.rb +13 -4
- data/app/controllers/profiler/profiles_controller.rb +2 -0
- data/app/views/profiler/profiles/show.html.erb +3 -1
- data/config/routes.rb +15 -8
- data/lib/profiler/cluster/master_client.rb +24 -6
- data/lib/profiler/cluster/security.rb +179 -0
- data/lib/profiler/cluster/slave_proxy.rb +24 -1
- data/lib/profiler/collectors/base_collector.rb +2 -0
- data/lib/profiler/collectors/console_collector.rb +2 -2
- data/lib/profiler/collectors/flamegraph_collector.rb +2 -2
- data/lib/profiler/collectors/i18n_collector.rb +1 -1
- data/lib/profiler/collectors/mailer_collector.rb +5 -5
- data/lib/profiler/configuration.rb +21 -1
- data/lib/profiler/console_profiler.rb +1 -1
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +2 -1
- data/lib/profiler/job_profiler.rb +2 -2
- data/lib/profiler/mcp/http_guard.rb +73 -0
- data/lib/profiler/mcp/server.rb +4 -1
- data/lib/profiler/middleware/toolbar_injector.rb +94 -6
- data/lib/profiler/models/profile.rb +14 -4
- data/lib/profiler/railtie.rb +5 -1
- data/lib/profiler/redaction.rb +98 -0
- data/lib/profiler/test_runner/run_store.rb +39 -2
- data/lib/profiler/test_runner/runner.rb +9 -3
- data/lib/profiler/version.rb +1 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc826508539b9892ab3317d52a95d77cdb162b4439a4086ae3c538239edb7eaa
|
|
4
|
+
data.tar.gz: 05c364a0cfeca030f37e635b35b150819f81f266de13137f98d33b9a9be29a53
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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"
|
|
@@ -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,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
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 =
|
|
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,
|
|
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 =
|
|
38
|
+
uri = master_uri("heartbeat")
|
|
39
39
|
body = { name: config.resolved_name }.to_json
|
|
40
|
-
resp = Net::HTTP.post(uri, body,
|
|
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
|