rails-profiler 0.30.9 → 0.30.11

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -0
  3. data/app/controllers/profiler/api/cluster_controller.rb +37 -1
  4. data/app/controllers/profiler/api/env_vars_controller.rb +6 -0
  5. data/app/controllers/profiler/api/slave_proxy_controller.rb +4 -2
  6. data/app/controllers/profiler/api/test_runner_controller.rb +3 -9
  7. data/app/controllers/profiler/application_controller.rb +12 -1
  8. data/app/controllers/profiler/profiles_controller.rb +2 -0
  9. data/config/routes.rb +15 -8
  10. data/lib/profiler/boot_env.rb +8 -0
  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 +25 -2
  20. data/lib/profiler/console_profiler.rb +1 -1
  21. data/lib/profiler/env_override_store.rb +11 -0
  22. data/lib/profiler/instrumentation/net_http_instrumentation.rb +2 -1
  23. data/lib/profiler/job_profiler.rb +2 -2
  24. data/lib/profiler/mcp/http_guard.rb +73 -0
  25. data/lib/profiler/mcp/server.rb +5 -2
  26. data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
  27. data/lib/profiler/mcp/tools/run_tests.rb +7 -1
  28. data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
  29. data/lib/profiler/models/profile.rb +14 -4
  30. data/lib/profiler/railtie.rb +5 -1
  31. data/lib/profiler/redaction.rb +98 -0
  32. data/lib/profiler/test_runner/discovery.rb +2 -2
  33. data/lib/profiler/test_runner/run_store.rb +39 -2
  34. data/lib/profiler/test_runner/runner.rb +144 -10
  35. data/lib/profiler/version.rb +1 -1
  36. data/lib/profiler.rb +2 -0
  37. metadata +6 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a3a3ebc22965fd603d63bafecef777474fc488912625ac2950e618566bce8f09
4
- data.tar.gz: e82aa1ce06e0f24cfb65b86fe01a942557851735f596edb3172c3fd00d7941f3
3
+ metadata.gz: e7bb3bed351c863507d670d29552cb6751bb023300728a48e9baf9c718a53659
4
+ data.tar.gz: 8af241a6ca0278f3ada6a4d0d88599e93b952f67057ba6b826ea36935976ba67
5
5
  SHA512:
6
- metadata.gz: 857313cb42832f5992a26897cbe6cef4d7dd66d6f59b5fccc63c8758db55da54cd6e3587e254e3d1410db561b668782ef8f3fcda73e4535235efba1e9a34c9be
7
- data.tar.gz: 1bd504a236e7c548011ceed8dcc1488b21bc89400d824306c2f90d8b1817290768a10264265ee093f78d295f873b72a250a00a96fc5fe04f54f85cd2d63597db
6
+ metadata.gz: 04d1ce9d5e1c0a2527ddb60280aa82e89e04ccc32a202a4f79e09b4a5fa87319b8f6d6c95a93f212a61cb4bc7213b92d15bb914567669a2f079eec502c075822
7
+ data.tar.gz: 91db3673cbcd3debdcf9fc1e02690601c4a70aaf10f4a17a070eed4b7f9359ae6261b5a9256ab43b9a13108aec0fa61677dddc9b735f72bf8103261f7e8b24f5
data/CHANGELOG.md CHANGED
@@ -19,6 +19,113 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.11] - 2026-10-04
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Cluster:** A profile page served by the master now finds profiles held by a slave even before
29
+ the slave proxy has been used once.
30
+
31
+ ### Security
32
+
33
+ - **MCP:** The HTTP transport at `/_profiler/mcp` is routed only when `mcp_enabled` is true and
34
+ `mcp_transport` is `:http`; it answers `404` otherwise. It used to be routed in every application,
35
+ whatever these options said, and to answer anyone: after the MCP handshake, any client could list
36
+ the tools and call those that write `ENV`, clear profiles or run the test suite. The stdio
37
+ transport (`rake profiler:mcp`) is unchanged.
38
+ - **MCP:** When it is routed, each request to `/_profiler/mcp` passes the profiler's own checks
39
+ before the MCP server sees it, and gets a `403` otherwise: the profiler must be enabled, the
40
+ request must pass `authorization_mode`, and a `POST` must carry `Content-Type: application/json`
41
+ with no foreign `Origin`, so that a page on another site cannot call the tools from your browser.
42
+ - **Cluster:** The cluster endpoints (`register`, `heartbeat`, `slaves` and the slave proxy) are
43
+ routed only on a node with the new `cluster_master = true`; they answer `404` otherwise. Every
44
+ application used to accept slave registrations.
45
+ - **Cluster:** The master and its slaves authenticate each other with the new shared
46
+ `cluster_secret`, sent in an `X-Profiler-Cluster-Secret` header and compared in constant time:
47
+ `register` and `heartbeat` refuse a missing or wrong secret, and everything while no secret is
48
+ configured. On a slave, the master's proxied calls are let in by the secret, so a master on
49
+ another machine no longer needs the slave to admit its address.
50
+ - **Cluster:** The master registers, and sends requests to, only the slave URLs allowed by the new
51
+ `cluster_allowed_slave_urls` (scheme, host and port, compared after normalization, and a path
52
+ prefix); the default refuses all. HTTPS is required, except for loopback addresses, for slave URLs
53
+ and for `master_url` (new `cluster_allow_insecure_http` to lift it), and the master no longer
54
+ returns the body of a redirect. Anyone who could register used to make the master fetch any host
55
+ and port it could reach and read the answer (server-side request forgery).
56
+ - **Cluster:** A path, profile token or MCP argument sent to a slave can no longer leave the slave's
57
+ `/_profiler/api/`: `?`, `#` and `%` are encoded into the path segment, and a `.`, `..` or `/`
58
+ segment is refused before any request, on the slave proxy, the profile pages and the MCP tools.
59
+ An encoded `..` or `?` used to reach other paths of the slave.
60
+ - **Cluster:** A `cluster_secret` shorter than 32 characters, or blank, is ignored as if none were
61
+ configured, with a warning at boot and an explicit error on registration. The master stores slave
62
+ URLs in their normalized form.
63
+ - **Cluster:** The `cluster_secret` is masked by value in everything a profile captures (params,
64
+ bodies, headers in and out, URLs, `ENV`, SQL binds, job and mailer arguments, and the free text of
65
+ every collector: log lines, exception messages, dumps, SQL text, console expressions), and in the
66
+ output of the test runner, whatever name it travels under and even with `redact_sensitive_data`
67
+ off, on top of the masking by name.
68
+ - **Cluster:** The `cluster_secret` is masked before text is shortened (flame graph names, I18n
69
+ values, console expressions and results, mail bodies and assigns, job arguments), so no prefix of
70
+ it is left, and the output of the test runner is masked across the pieces it is read in, so the
71
+ secret no longer comes back whole once they are joined. Only a secret the cluster accepts (32
72
+ characters or more) is masked by value.
73
+ - **Cluster:** The output of the test runner holds back an end that could start the secret until
74
+ the process has finished printing, a killed run included, and holds back nothing else, so
75
+ progress output is shown as it comes. Binary bodies stored in base64, incoming, response and
76
+ outbound, are masked on their raw bytes before they are encoded.
77
+ - **Upgrading:** Nothing to do if you use neither the MCP HTTP endpoint nor the cluster. Otherwise,
78
+ in `config/initializers/profiler.rb`:
79
+ - MCP over HTTP: set `config.mcp_enabled = true` and `config.mcp_transport = :http`. An
80
+ installation that used `/_profiler/mcp` while `mcp_transport` stayed at its default, `:stdio`,
81
+ must now set `:http`. The checks have no MCP-specific switch: `config.authorization_mode =
82
+ :allow_all` lets anybody who can reach the application call every tool, and
83
+ `config.api_forgery_protection = false` lets any website you visit make your browser call them.
84
+ - Cluster master: set `config.cluster_master = true`, `config.cluster_secret` (at least 32
85
+ characters, the same value on every node, from the environment) and
86
+ `config.cluster_allowed_slave_urls`. Slaves: set the same `config.cluster_secret`, and use HTTPS
87
+ URLs unless master and slave share the machine.
88
+ - To go back to the previous behavior: `config.cluster_require_secret = false` (with no
89
+ `cluster_secret`) accepts registrations from any client `authorization_mode` lets in, as in
90
+ 0.30.6; `config.cluster_allowed_slave_urls = :any` accepts any slave URL, which reopens the
91
+ server-side request forgery; `config.cluster_allow_insecure_http = true` sends the secret and the
92
+ profiles in clear to remote hosts.
93
+
94
+ ## [0.30.10] - 2026-10-04
95
+
96
+ <!-- stamped -->
97
+
98
+ ### Security
99
+
100
+ - **Test runner:** The test runner (the dashboard page and the MCP tool `run_tests`) only runs
101
+ the test files it discovers, `spec/**/*_spec.rb` and `test/**/*_test.rb` under the Rails root,
102
+ optionally with a line number (`spec/models/user_spec.rb:12`). Paths are compared after resolving
103
+ symbolic links. Any other path answers `422` over HTTP, or a tool error over MCP, and nothing is
104
+ started. The HTTP endpoint used to run any file under the Rails root, and `run_tests` any path at all.
105
+ - **Test runner:** A selection sent with the wrong framework (a `test/**/*_test.rb` file for
106
+ `rspec`, or a spec for `minitest`) now answers `422` too, and so does a path holding a null byte,
107
+ which used to raise an error.
108
+ - **Test runner:** The test process starts from the environment the shell gave Rails, copied when
109
+ the gem is loaded, plus the environment overrides set from the profiler, instead of the current
110
+ environment of the application. Overrides that make the test process, or the rbenv or asdf shim
111
+ that starts it, load or run other code are left out, with a warning naming them once: names
112
+ starting with `RUBY`, `GEM_`, `BUNDLE_`, `LD_`, `BASH_`, `RBENV_` and others, `NODE_OPTIONS`,
113
+ `PATH`, `SPEC_OPTS`, `PS4`, `SHELLOPTS`, any name holding a character other than a letter, a
114
+ digit or `_`, and the rest of the list in the README. Those keep their shell value, and so do
115
+ `DATABASE_URL` and `SECRET_KEY_BASE`, whose overrides were skipped but still reached the test
116
+ process, since an override is also written into the environment of the running application. A
117
+ variable deleted from the profiler is now unset in the test process, where it used to hold the
118
+ text `__profiler_deleted__`. Variables the application writes into `ENV` once loaded (`dotenv`
119
+ for instance) no longer reach the test process, which loads them itself when your test setup does.
120
+ The test process, which boots the same application, no longer replays the overrides on its own:
121
+ the runner marks it with `PROFILER_TEST_RUNNER_CHILD=1`, a name the env tools and the env vars
122
+ endpoint now refuse to set or delete (`422`, or a tool error over MCP).
123
+ - **Test runner:** Test files are found when the path of the application holds `[` or `{`.
124
+ - **Upgrading:** Nothing to do when you run the specs the profiler lists. To run any file under the
125
+ Rails root again, set `config.test_runner_allow_undiscovered_files = true`. The environment
126
+ overrides left out have no option, the list being a deny list that cannot be complete: set those
127
+ variables in the shell that starts Rails instead, the test process inherits them.
128
+
22
129
  ## [0.30.9] - 2026-10-04
23
130
 
24
131
  <!-- stamped -->
@@ -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
@@ -17,6 +17,12 @@ module Profiler
17
17
  return
18
18
  end
19
19
 
20
+ if Profiler::EnvOverrideStore.reserved_key?(key)
21
+ render json: { error: "#{Profiler::EnvOverrideStore::RESERVED_KEY_ERROR}; #{key} was left unchanged" },
22
+ status: :unprocessable_entity
23
+ return
24
+ end
25
+
20
26
  value = params[:value]
21
27
 
22
28
  if Profiler::Redaction.mask?(value)
@@ -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"
@@ -27,17 +27,11 @@ module Profiler
27
27
  return render json: { error: "No files selected" }, status: :unprocessable_entity
28
28
  end
29
29
 
30
- # Validate paths are within Rails root (prevent path traversal)
31
- root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
32
- files.each do |f|
33
- expanded = File.expand_path(File.join(root, f))
34
- unless expanded.start_with?(root)
35
- return render json: { error: "Invalid file path: #{f}" }, status: :unprocessable_entity
36
- end
37
- end
38
-
30
+ # Runner.start accepts only discovered test files
39
31
  run = Profiler::TestRunner::Runner.start(files: files, framework: framework)
40
32
  render json: run.to_h, status: :created
33
+ rescue Profiler::TestRunner::InvalidFileError => e
34
+ render json: { error: e.message }, status: :unprocessable_entity
41
35
  end
42
36
 
43
37
  def show
@@ -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"
@@ -18,6 +20,8 @@ module Profiler
18
20
  end
19
21
 
20
22
  def authorize_request
23
+ # Checked first, so that :allow_local does not log a refusal for the master's calls.
24
+ return if cluster_master_request?
21
25
  return if Profiler.configuration.authorized?(request)
22
26
 
23
27
  deny("Not authorized to access the profiler")
@@ -32,7 +36,14 @@ module Profiler
32
36
  # Rails' token, or the header every profiler client sends. A third-party page cannot add a
33
37
  # custom header without a CORS preflight, which is refused unless an origin was allowed.
34
38
  def verified_request?
35
- super || request.headers[Profiler::FORGERY_PROTECTION_HEADER].present?
39
+ super || request.headers[Profiler::FORGERY_PROTECTION_HEADER].present? || cluster_master_request?
40
+ end
41
+
42
+ # On a slave, the master's proxied calls carry the shared secret, which stands for both the
43
+ # access guard and the forgery header. Never true on a node without master_url, or without
44
+ # a configured secret.
45
+ def cluster_master_request?
46
+ Profiler.configuration.slave? && Cluster::Security.request_secret_valid?(request)
36
47
  end
37
48
 
38
49
  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
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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Profiler
4
+ # ENV as the shell gave it to the process, captured when the gem is loaded, before
5
+ # EnvOverrideStore#apply! or any override set from the profiler writes into ENV. The test
6
+ # runner builds the environment of the processes it starts from this copy, not from ENV.
7
+ BOOT_ENV = ENV.to_h.freeze unless const_defined?(:BOOT_ENV)
8
+ 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
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "uri"
5
+ require "active_support/security_utils"
6
+
7
+ module Profiler
8
+ module Cluster
9
+ # The rules a master and its slaves apply to each other: the shared secret they exchange,
10
+ # and the URLs a master agrees to send requests to.
11
+ module Security
12
+ SECRET_HEADER = "X-Profiler-Cluster-Secret"
13
+ SECRET_ENV_KEY = "HTTP_X_PROFILER_CLUSTER_SECRET"
14
+ # A shorter secret, or a blank one, is treated as if none were configured.
15
+ MIN_SECRET_LENGTH = 32
16
+ API_PREFIX = "/_profiler/api/"
17
+
18
+ class << self
19
+ # Whether a secret has to be presented. False only with cluster_require_secret = false
20
+ # and no cluster_secret, the behaviour of 0.30.6.
21
+ def secret_required?
22
+ config = Profiler.configuration
23
+ config.cluster_require_secret || configured_secret?
24
+ end
25
+
26
+ def configured_secret?
27
+ secret_problem.nil?
28
+ end
29
+
30
+ # Why the configured secret cannot be used, or nil when it can.
31
+ def secret_problem
32
+ secret = Profiler.configuration.cluster_secret.to_s
33
+ return "No config.cluster_secret is configured" if secret.strip.empty?
34
+ return nil if secret.strip.length >= MIN_SECRET_LENGTH
35
+
36
+ "config.cluster_secret is shorter than #{MIN_SECRET_LENGTH} characters, so it is ignored " \
37
+ "as if none were configured"
38
+ end
39
+
40
+ # Logged once at boot on a cluster node whose secret cannot be used, since every cluster
41
+ # request is then refused.
42
+ def warn_about_configuration(logger)
43
+ config = Profiler.configuration
44
+ return unless config.cluster_master? || config.slave?
45
+ return unless (problem = secret_problem) && secret_required?
46
+
47
+ logger.warn("[Profiler Cluster] #{problem}: registrations, heartbeats and proxied requests " \
48
+ "are refused. Generate one with `ruby -rsecurerandom -e 'puts SecureRandom.hex(32)'` " \
49
+ "and give the same value to every node.")
50
+ end
51
+
52
+ # Constant-time comparison of the secret a request carries with the configured one.
53
+ # Always false when no usable secret is configured.
54
+ def valid_secret?(presented)
55
+ return false unless configured_secret?
56
+
57
+ expected = Profiler.configuration.cluster_secret.to_s
58
+ return false if presented.to_s.empty?
59
+
60
+ ActiveSupport::SecurityUtils.secure_compare(presented.to_s, expected)
61
+ end
62
+
63
+ def request_secret_valid?(request)
64
+ valid_secret?(request.get_header(SECRET_ENV_KEY))
65
+ end
66
+
67
+ # The headers to send with a request to another node of the cluster.
68
+ def outgoing_headers
69
+ configured_secret? ? { SECRET_HEADER => Profiler.configuration.cluster_secret.to_s } : {}
70
+ end
71
+
72
+ # Returns nil when the master may send requests to this slave URL, otherwise the reason.
73
+ def slave_url_denial(url)
74
+ config = Profiler.configuration
75
+ target = normalize(url)
76
+ return "#{url.inspect} is not a valid http(s) URL" unless target
77
+
78
+ if target[:scheme] == "http" && !loopback_host?(target[:host]) && !config.cluster_allow_insecure_http
79
+ return "#{url} uses plain HTTP to a host that is not a loopback address: HTTPS is required " \
80
+ "(or set config.cluster_allow_insecure_http = true)"
81
+ end
82
+
83
+ allowed = config.cluster_allowed_slave_urls
84
+ return nil if allowed == :any
85
+ return nil if Array(allowed).any? { |entry| covers?(normalize(entry), target) }
86
+
87
+ "#{url} is not allowed by config.cluster_allowed_slave_urls"
88
+ end
89
+
90
+ # Same transport rule for the master URL a slave sends its secret to.
91
+ def master_url_denial(url)
92
+ target = normalize(url)
93
+ return "master_url #{url.inspect} is not a valid http(s) URL" unless target
94
+ return nil if target[:scheme] == "https" || loopback_host?(target[:host])
95
+ return nil if Profiler.configuration.cluster_allow_insecure_http
96
+
97
+ "master_url #{url} uses plain HTTP to a host that is not a loopback address: HTTPS is required " \
98
+ "(or set config.cluster_allow_insecure_http = true)"
99
+ end
100
+
101
+ # The URL as the registry keeps it: lower-case scheme and host, no default port, IPv6
102
+ # between brackets, path segments re-encoded, no trailing slash. Nil when not acceptable.
103
+ def normalized_url(url)
104
+ parts = normalize(url)
105
+ return nil unless parts
106
+
107
+ host = parts[:host].include?(":") ? "[#{parts[:host]}]" : parts[:host]
108
+ default_port = parts[:scheme] == "https" ? 443 : 80
109
+ port = parts[:port] == default_port ? "" : ":#{parts[:port]}"
110
+ path = parts[:segments].map { |segment| escape_segment(segment) }.join("/")
111
+ "#{parts[:scheme]}://#{host}#{port}#{path.empty? ? "" : "/#{path}"}"
112
+ end
113
+
114
+ # One path segment of a request to a slave, from a value that may come from a client (a
115
+ # proxied path, a profile token). "?", "#" and "%" are encoded, so they stay in the
116
+ # segment; ".", ".." and anything holding a "/" are refused, so the request cannot leave
117
+ # the slave's API.
118
+ def escape_segment(value)
119
+ segment = value.to_s
120
+ if segment.empty? || segment == "." || segment == ".." || segment.match?(%r{[/\\]})
121
+ raise Profiler::Error, "Refusing to send #{segment.inspect} as a path segment to a slave profiler"
122
+ end
123
+
124
+ URI.encode_www_form_component(segment).gsub("+", "%20")
125
+ end
126
+
127
+ # The final check on a path sent to a slave: under /_profiler/api/, with no dot or empty
128
+ # segment once decoded.
129
+ def api_path_denial(path)
130
+ raw = path.to_s.split("?", 2).first
131
+ return "#{path.inspect} is not under #{API_PREFIX}" unless raw.start_with?(API_PREFIX)
132
+
133
+ segments = raw.delete_prefix("/").split("/", -1)
134
+ decoded = segments.map { |segment| URI.decode_www_form_component(segment) }
135
+ return nil if decoded.none? { |segment| segment.empty? || segment == "." || segment == ".." || segment.include?("/") }
136
+
137
+ "#{path.inspect} holds an empty, \".\", \"..\" or encoded \"/\" path segment"
138
+ rescue ArgumentError
139
+ "#{path.inspect} is not a valid path"
140
+ end
141
+
142
+ # Scheme, host and port compared after normalization (case, default port, IPv6 brackets),
143
+ # and a path prefix compared segment by segment.
144
+ def covers?(entry, target)
145
+ return false unless entry
146
+
147
+ entry[:scheme] == target[:scheme] && entry[:host] == target[:host] && entry[:port] == target[:port] &&
148
+ (entry[:segments].empty? || target[:segments].first(entry[:segments].size) == entry[:segments])
149
+ end
150
+
151
+ # Returns { scheme:, host:, port:, segments: }, or nil for anything that is not a plain
152
+ # http(s) URL: user info, a query, a fragment, or a "." or ".." path segment are refused
153
+ # rather than interpreted.
154
+ def normalize(url)
155
+ uri = URI.parse(url.to_s.strip)
156
+ return nil unless uri.is_a?(URI::HTTP) && uri.host && !uri.host.empty?
157
+ return nil if uri.userinfo || uri.query || uri.fragment
158
+
159
+ segments = uri.path.to_s.split("/").reject(&:empty?)
160
+ decoded = segments.map { |segment| URI.decode_www_form_component(segment) }
161
+ return nil if decoded.any? { |segment| segment == "." || segment == ".." || segment.include?("/") }
162
+
163
+ { scheme: uri.scheme.downcase, host: uri.hostname.downcase, port: uri.port, segments: decoded }
164
+ rescue URI::Error, ArgumentError
165
+ nil
166
+ end
167
+
168
+ def loopback_host?(host)
169
+ host = host.to_s.downcase
170
+ return true if host == "localhost"
171
+
172
+ IPAddr.new(host).loopback?
173
+ rescue IPAddr::Error
174
+ false
175
+ end
176
+ end
177
+ end
178
+ end
179
+ 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
@@ -15,6 +16,15 @@ module Profiler
15
16
  entry = Profiler.slave_registry.find!(slave_name)
16
17
  raise Profiler::Error, "Slave profiler '#{slave_name}' is offline" if entry.status == "offline"
17
18
 
19
+ # Checked again on every use: the URL was registered earlier, under a configuration that
20
+ # may have changed since.
21
+ if (reason = Security.slave_url_denial(entry.url))
22
+ raise Profiler::Error, "Slave profiler '#{slave_name}' refused: #{reason}"
23
+ end
24
+ if Security.secret_required? && (problem = Security.secret_problem)
25
+ raise Profiler::Error, "#{problem}: requests to slave profilers are refused"
26
+ end
27
+
18
28
  @base_url = entry.url.to_s.chomp("/")
19
29
  @open_timeout = open_timeout || OPEN_TIMEOUT
20
30
  @read_timeout = read_timeout || READ_TIMEOUT
@@ -30,7 +40,7 @@ module Profiler
30
40
  end
31
41
 
32
42
  def load(token)
33
- data = get_json("/_profiler/api/profiles/#{token}")
43
+ data = get_json("/_profiler/api/profiles/#{Security.escape_segment(token)}")
34
44
  return nil unless data["token"] || data["profile"]
35
45
 
36
46
  raw = data["profile"] || data
@@ -69,7 +79,14 @@ module Profiler
69
79
 
70
80
  private
71
81
 
82
+ # Every request carries the secret, so its path must stay under the slave's API: values
83
+ # from clients are escaped by Security.escape_segment where they are interpolated, and
84
+ # this refuses whatever still reaches here with a dot segment.
72
85
  def build_uri(path, params = {})
86
+ if (reason = Security.api_path_denial(path))
87
+ raise Profiler::Error, "Refusing to send a request to a slave profiler: #{reason}"
88
+ end
89
+
73
90
  uri = URI("#{@base_url}#{path}")
74
91
  unless params.empty?
75
92
  uri.query = URI.encode_www_form(params.compact.transform_values(&:to_s))
@@ -87,12 +104,18 @@ module Profiler
87
104
 
88
105
  def request(uri, req)
89
106
  req[Profiler::FORGERY_PROTECTION_HEADER] = "1"
107
+ Security.outgoing_headers.each { |name, value| req[name] = value }
108
+ # Net::HTTP never follows a redirect; a 3xx is reported, without its body, rather than
109
+ # handed back as if the slave had answered.
90
110
  resp = Net::HTTP.start(uri.hostname, uri.port,
91
111
  open_timeout: @open_timeout, read_timeout: @read_timeout,
92
112
  use_ssl: uri.scheme == "https") do |http|
93
113
  http.request(req)
94
114
  end
95
115
  return {} if resp.code == "204"
116
+ if resp.code.to_s.start_with?("3")
117
+ raise Profiler::Error, "Slave profiler answered #{resp.code} (redirect not followed)"
118
+ end
96
119
 
97
120
  parse_response(resp)
98
121
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../redaction"
4
+
3
5
  module Profiler
4
6
  module Collectors
5
7
  class BaseCollector
@@ -13,7 +13,7 @@ module Profiler
13
13
  end
14
14
 
15
15
  def set_return_value(value)
16
- @return_value = value.inspect.slice(0, 10_000)
16
+ @return_value = Profiler::Redaction.truncate(value.inspect, 10_000, "")
17
17
  @return_value_captured = true
18
18
  rescue
19
19
  @return_value = "(uninspectable)"
@@ -50,7 +50,7 @@ module Profiler
50
50
  end
51
51
 
52
52
  def toolbar_summary
53
- { text: @expression.to_s[0, 30], color: "blue" }
53
+ { text: Profiler::Redaction.truncate(@expression, 30, ""), color: "blue" }
54
54
  end
55
55
  end
56
56
  end