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
|
@@ -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
|
|
@@ -13,7 +13,7 @@ module Profiler
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def set_return_value(value)
|
|
16
|
-
@return_value = value.inspect
|
|
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
|
|
53
|
+
{ text: Profiler::Redaction.truncate(@expression, 30, ""), color: "blue" }
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
end
|
|
@@ -86,7 +86,7 @@ module Profiler
|
|
|
86
86
|
|
|
87
87
|
sql = payload[:sql].to_s
|
|
88
88
|
add_event Models::TimelineEvent.new(
|
|
89
|
-
name:
|
|
89
|
+
name: Profiler::Redaction.truncate(sql, 80),
|
|
90
90
|
started_at: started,
|
|
91
91
|
finished_at: finished,
|
|
92
92
|
category: "sql",
|
|
@@ -100,7 +100,7 @@ module Profiler
|
|
|
100
100
|
op = name.split(".").first.sub("cache_", "")
|
|
101
101
|
key = payload[:key].to_s
|
|
102
102
|
add_event Models::TimelineEvent.new(
|
|
103
|
-
name: "cache_#{op}: #{
|
|
103
|
+
name: "cache_#{op}: #{Profiler::Redaction.truncate(key, 60)}",
|
|
104
104
|
started_at: started,
|
|
105
105
|
finished_at: finished,
|
|
106
106
|
category: "cache",
|
|
@@ -224,12 +224,12 @@ module Profiler
|
|
|
224
224
|
if mail.multipart?
|
|
225
225
|
html_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/html") }
|
|
226
226
|
text_part = mail.parts.find { |p| p.content_type.to_s.start_with?("text/plain") }
|
|
227
|
-
html = html_part&.body&.decoded&.then { |b| b
|
|
228
|
-
text = text_part&.body&.decoded&.then { |b| b
|
|
227
|
+
html = html_part&.body&.decoded&.then { |b| Profiler::Redaction.truncate(b, MAX_BODY_SIZE, "") }
|
|
228
|
+
text = text_part&.body&.decoded&.then { |b| Profiler::Redaction.truncate(b, MAX_BODY_SIZE, "") }
|
|
229
229
|
elsif mail.content_type.to_s.start_with?("text/html")
|
|
230
|
-
html = mail.body.decoded
|
|
230
|
+
html = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
|
|
231
231
|
else
|
|
232
|
-
text = mail.body.decoded
|
|
232
|
+
text = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
|
|
233
233
|
end
|
|
234
234
|
[html.nil? || html.empty? ? nil : html, text.nil? || text.empty? ? nil : text]
|
|
235
235
|
rescue StandardError
|
|
@@ -240,7 +240,7 @@ module Profiler
|
|
|
240
240
|
return "nil" if value.nil?
|
|
241
241
|
|
|
242
242
|
inspected = value.inspect
|
|
243
|
-
inspected
|
|
243
|
+
Profiler::Redaction.truncate(inspected, 300, "…")
|
|
244
244
|
rescue StandardError
|
|
245
245
|
value.to_s
|
|
246
246
|
end
|
|
@@ -41,7 +41,9 @@ module Profiler
|
|
|
41
41
|
:compress_bodies, :compress_body_threshold,
|
|
42
42
|
:redact_sensitive_data, :filter_parameters, :env_allowlist,
|
|
43
43
|
:name, :master_url, :self_url,
|
|
44
|
-
:cluster_heartbeat_interval, :cluster_offline_threshold
|
|
44
|
+
:cluster_heartbeat_interval, :cluster_offline_threshold,
|
|
45
|
+
:cluster_master, :cluster_secret, :cluster_require_secret,
|
|
46
|
+
:cluster_allowed_slave_urls, :cluster_allow_insecure_http
|
|
45
47
|
|
|
46
48
|
attr_writer :tmp_path
|
|
47
49
|
|
|
@@ -98,6 +100,12 @@ module Profiler
|
|
|
98
100
|
@self_url = nil
|
|
99
101
|
@cluster_heartbeat_interval = 15
|
|
100
102
|
@cluster_offline_threshold = 60
|
|
103
|
+
# Cluster security: see Profiler::Cluster::Security and the Cluster section of the README.
|
|
104
|
+
@cluster_master = false
|
|
105
|
+
@cluster_secret = nil
|
|
106
|
+
@cluster_require_secret = true
|
|
107
|
+
@cluster_allowed_slave_urls = []
|
|
108
|
+
@cluster_allow_insecure_http = false
|
|
101
109
|
end
|
|
102
110
|
|
|
103
111
|
def tmp_path
|
|
@@ -112,6 +120,18 @@ module Profiler
|
|
|
112
120
|
!slave?
|
|
113
121
|
end
|
|
114
122
|
|
|
123
|
+
# Whether this node serves the master-side cluster routes (register, heartbeat, slaves and
|
|
124
|
+
# the slave proxy). Read on each request by the route constraints.
|
|
125
|
+
def cluster_master?
|
|
126
|
+
cluster_master ? true : false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Whether /_profiler/mcp is routed. The stdio transport (rake profiler:mcp) does not depend
|
|
130
|
+
# on it.
|
|
131
|
+
def mcp_http_enabled?
|
|
132
|
+
mcp_enabled && mcp_transport.to_s == "http" ? true : false
|
|
133
|
+
end
|
|
134
|
+
|
|
115
135
|
def resolved_name
|
|
116
136
|
@name || (defined?(Rails) ? Rails.application.class.module_parent_name.underscore : "profiler")
|
|
117
137
|
end
|
|
@@ -39,7 +39,7 @@ module Profiler
|
|
|
39
39
|
profile = Models::Profile.new
|
|
40
40
|
profile.profile_type = "console"
|
|
41
41
|
profile.gem_version = Profiler::VERSION
|
|
42
|
-
profile.path =
|
|
42
|
+
profile.path = Redaction.truncate(@expression, 200)
|
|
43
43
|
profile.method = "CONSOLE"
|
|
44
44
|
|
|
45
45
|
console_collector = Collectors::ConsoleCollector.new(profile, expression: @expression)
|
|
@@ -150,7 +150,8 @@ module Profiler
|
|
|
150
150
|
mime = content_type.split(";").first.to_s.strip
|
|
151
151
|
|
|
152
152
|
if mime.match?(BINARY_CONTENT_TYPES)
|
|
153
|
-
|
|
153
|
+
# Masked on the raw bytes, before the encoding hides them from any later search.
|
|
154
|
+
{ body: Base64.strict_encode64(Redaction.hide_credentials(body.b)), encoding: "base64" }
|
|
154
155
|
else
|
|
155
156
|
text = Redaction.filter_body(body, content_type)
|
|
156
157
|
.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
|
|
@@ -118,11 +118,11 @@ module Profiler
|
|
|
118
118
|
|
|
119
119
|
Redaction.filter_value(args).map do |arg|
|
|
120
120
|
case arg
|
|
121
|
-
when String then
|
|
121
|
+
when String then Redaction.truncate(arg, 200)
|
|
122
122
|
when Numeric, TrueClass, FalseClass, NilClass then arg
|
|
123
123
|
else
|
|
124
124
|
inspected = arg.inspect
|
|
125
|
-
|
|
125
|
+
Redaction.truncate(inspected, 200)
|
|
126
126
|
end
|
|
127
127
|
rescue
|
|
128
128
|
arg.to_s
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Profiler
|
|
7
|
+
module MCP
|
|
8
|
+
# Checks a request to the MCP HTTP transport before the MCP server sees it, with the same
|
|
9
|
+
# rules as the profiler's controllers: the profiler must be enabled, the request authorized,
|
|
10
|
+
# and a POST must not be one that another site could forge from a browser.
|
|
11
|
+
#
|
|
12
|
+
# Forgery protection does not use the X-Profiler-Request header, which MCP clients do not
|
|
13
|
+
# send. A POST must carry Content-Type: application/json instead: a cross-site form or
|
|
14
|
+
# "simple" fetch can only send text/plain, form-urlencoded or multipart, and JSON needs a
|
|
15
|
+
# CORS preflight that the profiler grants to configured origins only. A browser also sends
|
|
16
|
+
# its Origin, which has to be the profiler's own or one of cors_allowed_origins. GET (the
|
|
17
|
+
# SSE stream) changes nothing, and DELETE always needs a preflight.
|
|
18
|
+
module HttpGuard
|
|
19
|
+
JSON_MEDIA_TYPE = "application/json"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# Returns nil when the request may proceed, otherwise a Rack response.
|
|
23
|
+
def call(env)
|
|
24
|
+
config = Profiler.configuration
|
|
25
|
+
return deny("Profiler is disabled") unless config.enabled
|
|
26
|
+
|
|
27
|
+
request = ActionDispatch::Request.new(env)
|
|
28
|
+
return deny("Not authorized to access the profiler") unless config.authorized?(request)
|
|
29
|
+
return nil unless config.api_forgery_protection
|
|
30
|
+
|
|
31
|
+
forgery_denial(request, config)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def forgery_denial(request, config)
|
|
37
|
+
origin = request.get_header("HTTP_ORIGIN")
|
|
38
|
+
if origin && !origin.empty? && !allowed_origin?(origin, request, config)
|
|
39
|
+
return deny("Cross-origin request refused: Origin #{origin} is not allowed")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if request.post? && media_type(request) != JSON_MEDIA_TYPE
|
|
43
|
+
return deny("A POST to the MCP endpoint needs Content-Type: #{JSON_MEDIA_TYPE}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def media_type(request)
|
|
50
|
+
request.get_header("CONTENT_TYPE").to_s.split(";").first.to_s.strip.downcase
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def allowed_origin?(origin, request, config)
|
|
54
|
+
return true if Array(config.cors_allowed_origins).include?(origin)
|
|
55
|
+
|
|
56
|
+
same_origin?(origin, request.base_url)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def same_origin?(origin, base_url)
|
|
60
|
+
a = URI.parse(origin)
|
|
61
|
+
b = URI.parse(base_url)
|
|
62
|
+
a.scheme.to_s.casecmp?(b.scheme.to_s) && a.host.to_s.casecmp?(b.host.to_s) && a.port == b.port
|
|
63
|
+
rescue URI::InvalidURIError
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def deny(message)
|
|
68
|
+
[403, { "content-type" => "application/json" }, [{ error: message }.to_json]]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "mcp"
|
|
4
|
+
require_relative "http_guard"
|
|
4
5
|
|
|
5
6
|
module Profiler
|
|
6
7
|
module MCP
|
|
@@ -12,7 +13,9 @@ module Profiler
|
|
|
12
13
|
|
|
13
14
|
def rack_app
|
|
14
15
|
# Memoized at class level — survives route reloads in development
|
|
15
|
-
@rack_app ||=
|
|
16
|
+
@rack_app ||= lambda do |env|
|
|
17
|
+
HttpGuard.call(env) || instance.http_transport.handle_request(Rack::Request.new(env))
|
|
18
|
+
end
|
|
16
19
|
end
|
|
17
20
|
end
|
|
18
21
|
|
|
@@ -1,9 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "strscan"
|
|
5
|
+
require "active_support/core_ext/string/output_safety"
|
|
6
|
+
|
|
3
7
|
module Profiler
|
|
4
8
|
module Middleware
|
|
5
9
|
class ToolbarInjector
|
|
6
|
-
|
|
10
|
+
# The only characters HTML counts as white space between attributes (\s also takes \v).
|
|
11
|
+
WS = "[\\t\\n\\f\\r ]"
|
|
12
|
+
# Elements whose content the browser does not parse as markup: a </body> inside one of
|
|
13
|
+
# them is text, not the end of the page. Each maps to the end tag that closes it.
|
|
14
|
+
RAW_TEXT_ELEMENTS = %w[script style textarea title xmp iframe noembed noframes noscript]
|
|
15
|
+
.to_h { |name| [name, %r{</#{name}(?=#{WS}|[/>])}i] }.freeze
|
|
16
|
+
# Never closed: whatever follows it is text.
|
|
17
|
+
PLAINTEXT = "plaintext"
|
|
18
|
+
# An attribute as the HTML tokenizer reads it: the name may start with = (or a quote),
|
|
19
|
+
# and a value only follows a name, after =. A quoted value may hold a > or a </body>.
|
|
20
|
+
ATTRIBUTE = %r{
|
|
21
|
+
[^\t\n\f\r\ />][^\t\n\f\r\ />=]*+
|
|
22
|
+
(?:#{WS}*+(?:=#{WS}*+(?:"[^"]*+"|'[^']*+'|[^\t\n\f\r\ >"'][^\t\n\f\r\ >]*+|(?=>))|(?!=)))
|
|
23
|
+
}x
|
|
24
|
+
# The rest of a tag, up to its >. A quoted value that is never closed fails the match,
|
|
25
|
+
# as does a tag without its >. Possessive, so that a failure costs no backtracking.
|
|
26
|
+
TAG_REST = %r{(?:#{WS}++|/|#{ATTRIBUTE})*+>}
|
|
27
|
+
# A whole start or end tag, read after its <: the slash and the name are captured.
|
|
28
|
+
TAG = %r{(/)?([a-z][^\t\n\f\r\ />]*+)#{TAG_REST}}i
|
|
7
29
|
|
|
8
30
|
def initialize(body, token, nonce = nil)
|
|
9
31
|
@body = body
|
|
@@ -13,9 +35,12 @@ module Profiler
|
|
|
13
35
|
|
|
14
36
|
def inject
|
|
15
37
|
content = extract_content(@body)
|
|
16
|
-
|
|
38
|
+
# Bytes, so that a page that is not valid UTF-8 can still be scanned.
|
|
39
|
+
bytes = content.b
|
|
40
|
+
position = closing_body_position(bytes)
|
|
41
|
+
return @body unless position
|
|
17
42
|
|
|
18
|
-
injected_content =
|
|
43
|
+
injected_content = bytes.insert(position, toolbar_html.b).force_encoding(content.encoding)
|
|
19
44
|
|
|
20
45
|
# Return as array for Rack compatibility
|
|
21
46
|
[injected_content]
|
|
@@ -23,12 +48,70 @@ module Profiler
|
|
|
23
48
|
|
|
24
49
|
private
|
|
25
50
|
|
|
51
|
+
# Byte offset of the </body> that closes the page: the last one outside comments, tags
|
|
52
|
+
# (an attribute value) and raw-text elements, where a page can carry the string
|
|
53
|
+
# "</body>" (a script building markup, a cached fragment in a final comment). nil when
|
|
54
|
+
# there is none, or when the page leaves a comment, a tag, a quoted value or a raw-text
|
|
55
|
+
# element open, or holds a <plaintext> or a double-escaped script: no toolbar rather than
|
|
56
|
+
# a toolbar in the wrong place.
|
|
57
|
+
def closing_body_position(bytes)
|
|
58
|
+
scanner = StringScanner.new(bytes)
|
|
59
|
+
position = nil
|
|
60
|
+
|
|
61
|
+
while scanner.skip_until(/</)
|
|
62
|
+
start = scanner.pos - 1
|
|
63
|
+
|
|
64
|
+
if scanner.skip(TAG)
|
|
65
|
+
name = scanner[2]
|
|
66
|
+
name = name.downcase if name.match?(/[A-Z]/)
|
|
67
|
+
|
|
68
|
+
if scanner[1]
|
|
69
|
+
position = start if name == "body"
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
return nil if name == PLAINTEXT
|
|
73
|
+
next unless (end_tag = RAW_TEXT_ELEMENTS[name])
|
|
74
|
+
|
|
75
|
+
text_start = scanner.pos
|
|
76
|
+
return nil unless scanner.skip_until(end_tag)
|
|
77
|
+
return nil if name == "script" && double_escaped?(bytes, text_start, scanner.pos - scanner.matched_size)
|
|
78
|
+
return nil unless scanner.skip(TAG_REST)
|
|
79
|
+
elsif scanner.match?(%r{/?[a-z]}i)
|
|
80
|
+
# A tag, or a quoted value in it, that is never closed.
|
|
81
|
+
return nil
|
|
82
|
+
elsif scanner.skip(/!--/)
|
|
83
|
+
# <!--> and <!---> are complete comments.
|
|
84
|
+
next if scanner.skip(/-?>/) || scanner.skip_until(/-->/)
|
|
85
|
+
|
|
86
|
+
return nil
|
|
87
|
+
elsif scanner.skip(/!\[CDATA\[/)
|
|
88
|
+
# A bogus comment up to the first > in HTML, a CDATA section up to ]]> in <svg> or
|
|
89
|
+
# <math>: go on only when both end at the same place.
|
|
90
|
+
return nil unless scanner.skip_until(/>/) && bytes.byteslice(scanner.pos - 3, 3) == "]]>"
|
|
91
|
+
elsif scanner.skip(%r{[!?/]})
|
|
92
|
+
# <!DOCTYPE>, <?xml ?>, </ not followed by a letter: a bogus comment, up to its >.
|
|
93
|
+
return nil unless scanner.skip_until(/>/)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
position
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# In a script, <!-- followed by <script enters a state where </script> does not close it.
|
|
101
|
+
# Only the script itself is searched, so that a page of many scripts is read in linear
|
|
102
|
+
# time.
|
|
103
|
+
def double_escaped?(bytes, from, to)
|
|
104
|
+
text = bytes.byteslice(from, to - from)
|
|
105
|
+
comment = text.index("<!--")
|
|
106
|
+
comment ? text.match?(%r{<script(?:#{WS}|[/>])}i, comment) : false
|
|
107
|
+
end
|
|
108
|
+
|
|
26
109
|
def ajax_interceptor_script
|
|
27
110
|
return "" unless Profiler.configuration.track_ajax
|
|
28
111
|
|
|
29
112
|
<<~HTML
|
|
30
113
|
<script#{nonce_attr}>
|
|
31
|
-
window.__PROFILER_PARENT_TOKEN__ =
|
|
114
|
+
window.__PROFILER_PARENT_TOKEN__ = #{js_token};
|
|
32
115
|
</script>
|
|
33
116
|
<script#{nonce_attr}>
|
|
34
117
|
#{ajax_interceptor_code}
|
|
@@ -63,15 +146,20 @@ module Profiler
|
|
|
63
146
|
<<~HTML
|
|
64
147
|
#{ajax_interceptor_script}
|
|
65
148
|
<style>#{toolbar_styles}</style>
|
|
66
|
-
<div id="profiler-toolbar" class="profiler-root" data-token="#{@token}"></div>
|
|
149
|
+
<div id="profiler-toolbar" class="profiler-root" data-token="#{ERB::Util.html_escape(@token)}"></div>
|
|
67
150
|
<button id="profiler-toolbar-toggle" class="profiler-root" title="Toggle profiler (Alt+P)"><span class="profiler-toggle-icon">▶</span></button>
|
|
68
151
|
<script#{nonce_attr}>(function(){var c=localStorage.getItem('profiler-toolbar-collapsed')==='true',t=localStorage.getItem('profiler-theme'),theme=t==='light'?'light':t==='dark'?'dark':(window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'),el=document.getElementById('profiler-toolbar'),tog=document.getElementById('profiler-toolbar-toggle');if(c){el.style.cssText='animation:none!important;transform:translateX(calc(100% + 44px))';tog.dataset.collapsed='true';}el.setAttribute('data-theme',theme);tog.setAttribute('data-theme',theme);})();</script>
|
|
69
152
|
<script src="/_profiler/assets/profiler-toolbar.js" defer#{nonce_attr}></script>
|
|
70
153
|
HTML
|
|
71
154
|
end
|
|
72
155
|
|
|
156
|
+
# The token as a JavaScript string literal that cannot close the <script> element.
|
|
157
|
+
def js_token
|
|
158
|
+
ERB::Util.json_escape(@token.to_s.to_json)
|
|
159
|
+
end
|
|
160
|
+
|
|
73
161
|
def nonce_attr
|
|
74
|
-
@nonce ? " nonce=\"#{@nonce}\"" : ""
|
|
162
|
+
@nonce ? " nonce=\"#{ERB::Util.html_escape(@nonce)}\"" : ""
|
|
75
163
|
end
|
|
76
164
|
|
|
77
165
|
# Thermal design system — self-contained CSS for the injected toolbar.
|