rails-profiler 0.30.10 → 0.30.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/app/assets/builds/profiler.js +20 -65
  4. data/app/controllers/profiler/api/cluster_controller.rb +37 -1
  5. data/app/controllers/profiler/api/slave_proxy_controller.rb +4 -2
  6. data/app/controllers/profiler/api/toolbar_controller.rb +0 -2
  7. data/app/controllers/profiler/application_controller.rb +13 -4
  8. data/app/controllers/profiler/profiles_controller.rb +2 -0
  9. data/app/views/profiler/profiles/show.html.erb +3 -1
  10. data/config/routes.rb +15 -8
  11. data/lib/profiler/cluster/master_client.rb +24 -6
  12. data/lib/profiler/cluster/security.rb +179 -0
  13. data/lib/profiler/cluster/slave_proxy.rb +24 -1
  14. data/lib/profiler/collectors/base_collector.rb +2 -0
  15. data/lib/profiler/collectors/console_collector.rb +2 -2
  16. data/lib/profiler/collectors/flamegraph_collector.rb +2 -2
  17. data/lib/profiler/collectors/i18n_collector.rb +1 -1
  18. data/lib/profiler/collectors/mailer_collector.rb +5 -5
  19. data/lib/profiler/configuration.rb +21 -1
  20. data/lib/profiler/console_profiler.rb +1 -1
  21. data/lib/profiler/instrumentation/net_http_instrumentation.rb +2 -1
  22. data/lib/profiler/job_profiler.rb +2 -2
  23. data/lib/profiler/mcp/http_guard.rb +73 -0
  24. data/lib/profiler/mcp/server.rb +4 -1
  25. data/lib/profiler/middleware/toolbar_injector.rb +94 -6
  26. data/lib/profiler/models/profile.rb +14 -4
  27. data/lib/profiler/railtie.rb +5 -1
  28. data/lib/profiler/redaction.rb +98 -0
  29. data/lib/profiler/test_runner/run_store.rb +39 -2
  30. data/lib/profiler/test_runner/runner.rb +9 -3
  31. data/lib/profiler/version.rb +1 -1
  32. metadata +3 -1
@@ -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
@@ -86,7 +86,7 @@ module Profiler
86
86
 
87
87
  sql = payload[:sql].to_s
88
88
  add_event Models::TimelineEvent.new(
89
- name: sql.length > 80 ? "#{sql[0, 80]}..." : sql,
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}: #{key.length > 60 ? "#{key[0, 60]}..." : key}",
103
+ name: "cache_#{op}: #{Profiler::Redaction.truncate(key, 60)}",
104
104
  started_at: started,
105
105
  finished_at: finished,
106
106
  category: "cache",
@@ -93,7 +93,7 @@ module Profiler
93
93
  private
94
94
 
95
95
  def truncate(str, max = 100)
96
- str.length > max ? "#{str[0, max]}…" : str
96
+ Profiler::Redaction.truncate(str, max, "…")
97
97
  end
98
98
  end
99
99
  end
@@ -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[0, MAX_BODY_SIZE] }
228
- text = text_part&.body&.decoded&.then { |b| b[0, MAX_BODY_SIZE] }
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[0, MAX_BODY_SIZE]
230
+ html = Profiler::Redaction.truncate(mail.body.decoded, MAX_BODY_SIZE, "")
231
231
  else
232
- text = mail.body.decoded[0, MAX_BODY_SIZE]
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.length > 300 ? "#{inspected[0, 300]}…" : 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 = @expression.length > 200 ? "#{@expression[0, 200]}..." : @expression
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
- { body: Base64.strict_encode64(body.b), encoding: "base64" }
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 arg.length > 200 ? "#{arg[0, 200]}..." : arg
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
- inspected.length > 200 ? "#{inspected[0, 200]}..." : inspected
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
@@ -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 ||= ->(env) { instance.http_transport.handle_request(Rack::Request.new(env)) }
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
- CLOSING_BODY_TAG = "</body>"
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
- return @body unless content.include?(CLOSING_BODY_TAG)
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 = content.sub(CLOSING_BODY_TAG, toolbar_html + CLOSING_BODY_TAG)
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__ = '#{@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">&#9654;</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.