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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +80 -0
- data/app/assets/builds/profiler-toolbar.js +2 -1
- data/app/assets/builds/profiler.js +17 -3
- data/app/controllers/profiler/api/ajax_controller.rb +0 -2
- data/app/controllers/profiler/api/cluster_controller.rb +0 -2
- data/app/controllers/profiler/api/console_controller.rb +0 -2
- data/app/controllers/profiler/api/env_vars_controller.rb +22 -7
- data/app/controllers/profiler/api/events_controller.rb +0 -1
- data/app/controllers/profiler/api/explain_controller.rb +0 -2
- data/app/controllers/profiler/api/function_profiling_controller.rb +0 -2
- data/app/controllers/profiler/api/jobs_controller.rb +0 -2
- data/app/controllers/profiler/api/outbound_http_controller.rb +0 -2
- data/app/controllers/profiler/api/profiles_controller.rb +0 -2
- data/app/controllers/profiler/api/slave_proxy_controller.rb +0 -2
- data/app/controllers/profiler/api/test_runner_controller.rb +0 -2
- data/app/controllers/profiler/api/tests_controller.rb +0 -2
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -1
- data/app/controllers/profiler/application_controller.rb +33 -2
- data/app/controllers/profiler/assets_controller.rb +3 -0
- data/app/controllers/profiler/profiles_controller.rb +0 -9
- data/lib/profiler/cluster/master_client.rb +4 -2
- data/lib/profiler/cluster/slave_proxy.rb +1 -0
- data/lib/profiler/collectors/database_collector.rb +5 -1
- data/lib/profiler/collectors/env_collector.rb +1 -1
- data/lib/profiler/collectors/mailer_collector.rb +13 -1
- data/lib/profiler/collectors/request_collector.rb +1 -1
- data/lib/profiler/configuration.rb +42 -4
- data/lib/profiler/explain_runner.rb +4 -0
- data/lib/profiler/instrumentation/net_http_instrumentation.rb +6 -4
- data/lib/profiler/job_profiler.rb +2 -1
- data/lib/profiler/local_request.rb +163 -0
- data/lib/profiler/mcp/tools/list_env_vars.rb +4 -3
- data/lib/profiler/mcp/tools/reset_env_var.rb +2 -1
- data/lib/profiler/mcp/tools/set_env_var.rb +5 -0
- data/lib/profiler/middleware/cors_middleware.rb +47 -35
- data/lib/profiler/models/profile.rb +9 -3
- data/lib/profiler/railtie.rb +4 -5
- data/lib/profiler/redaction.rb +421 -0
- data/lib/profiler/version.rb +1 -1
- data/lib/profiler.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f606ebbea7d5aecac4c62d789f07ddab9b4942269d4f8246441b3f37bc3220e7
|
|
4
|
+
data.tar.gz: 53f89719e215f799ba93d436fbace90ee45b38b0ec8d7f453cd2d2410e50cc0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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", ...
|
|
4174
|
+
headers: data !== void 0 ? { "Content-Type": "application/json", ...baseHeaders } : baseHeaders,
|
|
4174
4175
|
body: data !== void 0 ? JSON.stringify(data) : void 0
|
|
4175
4176
|
});
|
|
4176
4177
|
if (res.status === 204) return {};
|
|
@@ -3456,10 +3456,11 @@
|
|
|
3456
3456
|
const { method, params, data, headers = {}, signal } = config;
|
|
3457
3457
|
const url = resolvedUrl;
|
|
3458
3458
|
const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
|
|
3459
|
+
const baseHeaders = { "X-Profiler-Request": "1", ...headers };
|
|
3459
3460
|
const res = await fetch(url + qs, {
|
|
3460
3461
|
method,
|
|
3461
3462
|
signal,
|
|
3462
|
-
headers: data !== void 0 ? { "Content-Type": "application/json", ...
|
|
3463
|
+
headers: data !== void 0 ? { "Content-Type": "application/json", ...baseHeaders } : baseHeaders,
|
|
3463
3464
|
body: data !== void 0 ? JSON.stringify(data) : void 0
|
|
3464
3465
|
});
|
|
3465
3466
|
if (res.status === 204) return {};
|
|
@@ -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
|
|
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
|
-
|
|
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,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 =
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
@@ -5,8 +5,6 @@ require "profiler/explain_runner"
|
|
|
5
5
|
module Profiler
|
|
6
6
|
module Api
|
|
7
7
|
class ExplainController < ApplicationController
|
|
8
|
-
skip_before_action :verify_authenticity_token
|
|
9
|
-
|
|
10
8
|
def create
|
|
11
9
|
unless Profiler.configuration.enabled
|
|
12
10
|
return render json: { error: "EXPLAIN is only available when the profiler is enabled" }, status: :forbidden
|
|
@@ -5,8 +5,6 @@ require_relative "../../../../lib/profiler/cluster/slave_proxy"
|
|
|
5
5
|
module Profiler
|
|
6
6
|
module Api
|
|
7
7
|
class SlaveProxyController < Profiler::ApplicationController
|
|
8
|
-
skip_before_action :verify_authenticity_token
|
|
9
|
-
|
|
10
8
|
def forward
|
|
11
9
|
proxy = Cluster::SlaveProxy.new(params[:slave_name])
|
|
12
10
|
sub_path = params[:path].to_s
|
|
@@ -8,8 +8,6 @@ module Profiler
|
|
|
8
8
|
class TestRunnerController < ApplicationController
|
|
9
9
|
include ActionController::Live
|
|
10
10
|
|
|
11
|
-
skip_before_action :verify_authenticity_token
|
|
12
|
-
|
|
13
11
|
def files
|
|
14
12
|
framework = params[:framework]
|
|
15
13
|
tree = Profiler::TestRunner::Discovery.files(framework: framework)
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
class ApplicationController < ActionController::Base
|
|
5
|
-
protect_from_forgery with: :exception
|
|
6
|
-
|
|
7
5
|
layout "profiler/application"
|
|
8
6
|
|
|
9
7
|
before_action :check_authorization
|
|
8
|
+
before_action :authorize_request
|
|
9
|
+
# Declared after the access checks, so that an unauthorized request gets its 403 first.
|
|
10
|
+
protect_from_forgery with: :exception
|
|
10
11
|
|
|
11
12
|
private
|
|
12
13
|
|
|
@@ -16,6 +17,36 @@ module Profiler
|
|
|
16
17
|
end
|
|
17
18
|
end
|
|
18
19
|
|
|
20
|
+
def authorize_request
|
|
21
|
+
return if Profiler.configuration.authorized?(request)
|
|
22
|
+
|
|
23
|
+
deny("Not authorized to access the profiler")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Decided by the profiler's own setting, not by the application's allow_forgery_protection,
|
|
27
|
+
# which the config/environments/test.rb generated by Rails turns off.
|
|
28
|
+
def protect_against_forgery?
|
|
29
|
+
Profiler.configuration.api_forgery_protection ? true : false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Rails' token, or the header every profiler client sends. A third-party page cannot add a
|
|
33
|
+
# custom header without a CORS preflight, which is refused unless an origin was allowed.
|
|
34
|
+
def verified_request?
|
|
35
|
+
super || request.headers[Profiler::FORGERY_PROTECTION_HEADER].present?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def handle_unverified_request
|
|
39
|
+
deny("Missing #{Profiler::FORGERY_PROTECTION_HEADER} header or CSRF token")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def deny(message)
|
|
43
|
+
if controller_path.start_with?("profiler/api/")
|
|
44
|
+
render json: { error: message }, status: :forbidden
|
|
45
|
+
else
|
|
46
|
+
render plain: message, status: :forbidden
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
19
50
|
def build_child_jobs(profile)
|
|
20
51
|
storage = @resolved_storage || Profiler.storage
|
|
21
52
|
storage.find_by_parent(profile.token)
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
class AssetsController < ApplicationController
|
|
5
|
+
# The gem's own static JS and CSS: no application data, cached publicly. Every piece of
|
|
6
|
+
# data they display comes from the API, which keeps the authorization check.
|
|
5
7
|
skip_before_action :verify_authenticity_token
|
|
6
8
|
skip_before_action :check_authorization
|
|
9
|
+
skip_before_action :authorize_request
|
|
7
10
|
|
|
8
11
|
def toolbar_js
|
|
9
12
|
path = Profiler::Engine.root.join("app", "assets", "builds", "profiler-toolbar.js")
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
module Profiler
|
|
4
4
|
class ProfilesController < ApplicationController
|
|
5
|
-
before_action :allow_iframe_embedding, only: [:show], if: -> { params[:embed] == "true" }
|
|
6
|
-
|
|
7
5
|
def index
|
|
8
6
|
limit = params[:limit]&.to_i || 50
|
|
9
7
|
offset = params[:offset]&.to_i || 0
|
|
@@ -158,13 +156,6 @@ module Profiler
|
|
|
158
156
|
end
|
|
159
157
|
end
|
|
160
158
|
|
|
161
|
-
def allow_iframe_embedding
|
|
162
|
-
response.headers.delete('X-Frame-Options')
|
|
163
|
-
# Don't set frame-ancestors CSP to allow Chrome extensions to embed
|
|
164
|
-
# Mark that CSP should not be set by middleware
|
|
165
|
-
request.env['profiler.skip_csp'] = true
|
|
166
|
-
end
|
|
167
|
-
|
|
168
159
|
def filter_profiles(profiles)
|
|
169
160
|
filtered = profiles
|
|
170
161
|
|
|
@@ -24,7 +24,8 @@ module Profiler
|
|
|
24
24
|
config = Profiler.configuration
|
|
25
25
|
uri = URI("#{config.master_url}/_profiler/api/cluster/register")
|
|
26
26
|
body = { name: config.resolved_name, url: config.self_url }.to_json
|
|
27
|
-
resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json"
|
|
27
|
+
resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json",
|
|
28
|
+
Profiler::FORGERY_PROTECTION_HEADER => "1")
|
|
28
29
|
unless resp.code.to_i.between?(200, 299)
|
|
29
30
|
raise "Master returned #{resp.code}: #{resp.body.to_s.slice(0, 200)}"
|
|
30
31
|
end
|
|
@@ -36,7 +37,8 @@ module Profiler
|
|
|
36
37
|
config = Profiler.configuration
|
|
37
38
|
uri = URI("#{config.master_url}/_profiler/api/cluster/heartbeat")
|
|
38
39
|
body = { name: config.resolved_name }.to_json
|
|
39
|
-
resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json"
|
|
40
|
+
resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json",
|
|
41
|
+
Profiler::FORGERY_PROTECTION_HEADER => "1")
|
|
40
42
|
raise "Heartbeat rejected #{resp.code}" unless resp.code.to_i.between?(200, 299)
|
|
41
43
|
end
|
|
42
44
|
|
|
@@ -96,11 +96,15 @@ module Profiler
|
|
|
96
96
|
|
|
97
97
|
binds.map do |bind|
|
|
98
98
|
if bind.respond_to?(:value)
|
|
99
|
-
bind.
|
|
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
|
|
@@ -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] =
|
|
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 = :
|
|
62
|
+
@authorization_mode = :allow_local
|
|
39
63
|
@authorize_block = nil
|
|
40
64
|
@max_profiles = 100
|
|
41
|
-
@extension_cors_enabled =
|
|
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
|