rails-profiler 0.30.5 → 0.30.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -0
  3. data/app/assets/builds/profiler-toolbar.js +2 -1
  4. data/app/assets/builds/profiler.js +17 -3
  5. data/app/controllers/profiler/api/ajax_controller.rb +0 -2
  6. data/app/controllers/profiler/api/cluster_controller.rb +0 -2
  7. data/app/controllers/profiler/api/console_controller.rb +0 -2
  8. data/app/controllers/profiler/api/env_vars_controller.rb +22 -7
  9. data/app/controllers/profiler/api/events_controller.rb +0 -1
  10. data/app/controllers/profiler/api/explain_controller.rb +0 -2
  11. data/app/controllers/profiler/api/function_profiling_controller.rb +0 -2
  12. data/app/controllers/profiler/api/jobs_controller.rb +0 -2
  13. data/app/controllers/profiler/api/outbound_http_controller.rb +0 -2
  14. data/app/controllers/profiler/api/profiles_controller.rb +0 -2
  15. data/app/controllers/profiler/api/slave_proxy_controller.rb +0 -2
  16. data/app/controllers/profiler/api/test_runner_controller.rb +0 -2
  17. data/app/controllers/profiler/api/tests_controller.rb +0 -2
  18. data/app/controllers/profiler/api/toolbar_controller.rb +0 -1
  19. data/app/controllers/profiler/application_controller.rb +33 -2
  20. data/app/controllers/profiler/assets_controller.rb +3 -0
  21. data/app/controllers/profiler/profiles_controller.rb +0 -9
  22. data/lib/profiler/cluster/master_client.rb +4 -2
  23. data/lib/profiler/cluster/slave_proxy.rb +1 -0
  24. data/lib/profiler/collectors/database_collector.rb +5 -1
  25. data/lib/profiler/collectors/env_collector.rb +1 -1
  26. data/lib/profiler/collectors/mailer_collector.rb +13 -1
  27. data/lib/profiler/collectors/request_collector.rb +1 -1
  28. data/lib/profiler/configuration.rb +42 -4
  29. data/lib/profiler/explain_runner.rb +4 -0
  30. data/lib/profiler/instrumentation/net_http_instrumentation.rb +6 -4
  31. data/lib/profiler/job_profiler.rb +2 -1
  32. data/lib/profiler/local_request.rb +163 -0
  33. data/lib/profiler/mcp/tools/list_env_vars.rb +4 -3
  34. data/lib/profiler/mcp/tools/reset_env_var.rb +2 -1
  35. data/lib/profiler/mcp/tools/set_env_var.rb +5 -0
  36. data/lib/profiler/middleware/cors_middleware.rb +47 -35
  37. data/lib/profiler/models/profile.rb +9 -3
  38. data/lib/profiler/railtie.rb +4 -5
  39. data/lib/profiler/redaction.rb +421 -0
  40. data/lib/profiler/version.rb +1 -1
  41. data/lib/profiler.rb +1 -0
  42. metadata +4 -2
@@ -5,6 +5,7 @@ require "base64"
5
5
  require "zlib"
6
6
  require "stringio"
7
7
  require "securerandom"
8
+ require_relative "../redaction"
8
9
 
9
10
  module Profiler
10
11
  module Instrumentation
@@ -20,7 +21,7 @@ module Profiler
20
21
  host = address.to_s
21
22
  return super if NetHttpInstrumentation.skip_host?(host)
22
23
 
23
- url = build_url(host, port, req.path, use_ssl?)
24
+ url = Redaction.filter_url(build_url(host, port, req.path, use_ssl?))
24
25
  req_body = req.body.to_s
25
26
  # Fallback: body may be passed as the 2nd argument and only applied
26
27
  # to req inside super via req.set_body_internal(body)
@@ -43,7 +44,7 @@ module Profiler
43
44
  req_body = ""
44
45
  end
45
46
  end
46
- req_headers = req.to_hash.transform_values { |v| v.join(", ") }
47
+ req_headers = Redaction.filter_headers(req.to_hash.transform_values { |v| v.join(", ") })
47
48
  req_content_type = req["content-type"].to_s
48
49
  processed_req = req_body.empty? ? { body: nil, encoding: "text" } : NetHttpInstrumentation.process_body(req_body, req_content_type)
49
50
 
@@ -83,7 +84,7 @@ module Profiler
83
84
  entry,
84
85
  status: response.code.to_i,
85
86
  duration: duration,
86
- response_headers: response.to_hash.transform_values { |v| v.join(", ") },
87
+ response_headers: Redaction.filter_headers(response.to_hash.transform_values { |v| v.join(", ") }),
87
88
  response_body: processed_resp[:body],
88
89
  response_body_encoding: processed_resp[:encoding],
89
90
  response_size: resp_body_raw.bytesize
@@ -151,7 +152,8 @@ module Profiler
151
152
  if mime.match?(BINARY_CONTENT_TYPES)
152
153
  { body: Base64.strict_encode64(body.b), encoding: "base64" }
153
154
  else
154
- text = body.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
155
+ text = Redaction.filter_body(body, content_type)
156
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
155
157
  { body: text, encoding: "text" }
156
158
  end
157
159
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "models/profile"
4
4
  require_relative "current_context"
5
+ require_relative "redaction"
5
6
  require_relative "collectors/job_collector"
6
7
  require_relative "collectors/database_collector"
7
8
  require_relative "collectors/cache_collector"
@@ -115,7 +116,7 @@ module Profiler
115
116
  def sanitize_arguments(args)
116
117
  return [] unless args
117
118
 
118
- args.map do |arg|
119
+ Redaction.filter_value(args).map do |arg|
119
120
  case arg
120
121
  when String then arg.length > 200 ? "#{arg[0, 200]}..." : arg
121
122
  when Numeric, TrueClass, FalseClass, NilClass then arg
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Profiler
6
+ # Decides whether a request comes from this machine, for authorization_mode :allow_local.
7
+ #
8
+ # The client address is read from REMOTE_ADDR only. Forwarding headers (X-Forwarded-For,
9
+ # X-Real-IP, Forwarded, X-Forwarded-Host) can be forged, so they never grant access: they
10
+ # can only refuse it, when a local reverse proxy reports a remote client. The Host header
11
+ # has to be a local name, or one the application allows in config.hosts, which defeats DNS
12
+ # rebinding: a rebound page reaches 127.0.0.1 under the attacker's own domain name. The
13
+ # test environment also accepts the default hosts of rack-test and of Rails integration
14
+ # tests, reserved names (RFC 2606) that no attacker can point at a server of his own.
15
+ module LocalRequest
16
+ FORWARDING_HEADERS = {
17
+ "HTTP_X_FORWARDED_FOR" => "X-Forwarded-For",
18
+ "HTTP_X_REAL_IP" => "X-Real-IP",
19
+ "HTTP_FORWARDED" => "Forwarded"
20
+ }.freeze
21
+
22
+ TEST_HOSTS = %w[www.example.com example.com example.org].freeze
23
+
24
+ @warned = false
25
+ @warn_mutex = Mutex.new
26
+
27
+ class << self
28
+ # Returns nil when the request is local, otherwise a sentence naming the cause.
29
+ def denial_reason(request)
30
+ remote_addr = request.get_header("REMOTE_ADDR").to_s
31
+ unless loopback_address?(remote_addr)
32
+ return "REMOTE_ADDR #{remote_addr.empty? ? "(missing)" : remote_addr} is not a loopback address"
33
+ end
34
+
35
+ FORWARDING_HEADERS.each do |key, name|
36
+ value = request.get_header(key)
37
+ next if value.nil? || value.empty?
38
+
39
+ nodes = key == "HTTP_FORWARDED" ? forwarded_param(value, "for") : value.split(",")
40
+ remote = nodes.map { |node| strip_port(node) }.find { |address| !loopback_address?(address) }
41
+ return "the #{name} header reports a non-local client (#{remote})" if remote
42
+ end
43
+
44
+ host_denial_reason(request)
45
+ end
46
+
47
+ def local?(request)
48
+ denial_reason(request).nil?
49
+ end
50
+
51
+ # Logs why a request was refused, once per process, so that an application running in
52
+ # Docker does not just stop being profiled with no explanation.
53
+ def warn_once(reason)
54
+ @warn_mutex.synchronize do
55
+ return if @warned
56
+
57
+ @warned = true
58
+ end
59
+
60
+ message = "[Profiler] Request refused or not profiled by authorization_mode :allow_local: #{reason}. " \
61
+ "The profiler only serves and captures requests made from this machine. " \
62
+ "If the application runs in Docker or behind a remote proxy, either set " \
63
+ "config.authorization_mode = :allow_authorized with a config.authorize_with block " \
64
+ "that admits your network (see the Access control section of the README), or set " \
65
+ "config.authorization_mode = :allow_all, which offers no protection at all. " \
66
+ "This message is logged once per process."
67
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
68
+ Rails.logger.warn(message)
69
+ else
70
+ warn(message)
71
+ end
72
+ end
73
+
74
+ def reset_warning!
75
+ @warn_mutex.synchronize { @warned = false }
76
+ end
77
+
78
+ def loopback_address?(value)
79
+ address = value.to_s.strip
80
+ address = address[1..-2] if address.start_with?("[") && address.end_with?("]")
81
+ # Only an IPv6 address carries a zone identifier ("fe80::1%eth0").
82
+ address = address.sub(/%.*\z/, "") if address.include?(":")
83
+ return false if address.empty?
84
+
85
+ ip = IPAddr.new(address)
86
+ ip = ip.native if ip.ipv6? && ip.ipv4_mapped?
87
+ ip.loopback?
88
+ rescue IPAddr::Error
89
+ false
90
+ end
91
+
92
+ private
93
+
94
+ def host_denial_reason(request)
95
+ # A request with no Host header does not come from a browser, so it cannot be a
96
+ # rebound page; REMOTE_ADDR alone decides.
97
+ raw_host = request.get_header("HTTP_HOST").to_s
98
+ unless raw_host.empty?
99
+ host = host_name(raw_host)
100
+ return "the Host header #{host.inspect} is not a local name" unless allowed_host?(host)
101
+ end
102
+
103
+ forwarded_hosts = request.get_header("HTTP_X_FORWARDED_HOST").to_s.split(",")
104
+ remote = forwarded_hosts.map { |h| host_name(h) }.find { |h| !allowed_host?(h) }
105
+ return "the X-Forwarded-Host header #{remote.inspect} is not a local name" if remote
106
+
107
+ forwarded = request.get_header("HTTP_FORWARDED").to_s
108
+ remote = forwarded_param(forwarded, "host").map { |h| host_name(h) }.find { |h| !allowed_host?(h) }
109
+ return "the Forwarded header names a non-local host (#{remote.inspect})" if remote
110
+
111
+ nil
112
+ end
113
+
114
+ def test_environment?
115
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test?
116
+ end
117
+
118
+ # Values of one parameter (for=, host=) of a Forwarded header (RFC 7239), quotes removed.
119
+ def forwarded_param(value, param)
120
+ value.split(/[,;]/).filter_map do |pair|
121
+ name, node = pair.split("=", 2)
122
+ next unless name.to_s.strip.casecmp?(param)
123
+
124
+ node.to_s.strip.delete_prefix('"').delete_suffix('"')
125
+ end
126
+ end
127
+
128
+ # "127.0.0.1:5555" -> "127.0.0.1", "[::1]:5555" -> "::1", "localhost:3000" -> "localhost".
129
+ # Any other form, a bare IPv6 address included, is returned as is: a malformed value is
130
+ # then refused unless it is local by itself.
131
+ def strip_port(value)
132
+ value = value.to_s.strip
133
+ match = value.match(/\A\[([^\]]+)\](?::\d+)?\z/) || value.match(/\A([^:]+)(?::\d+)?\z/)
134
+ match ? match[1] : value
135
+ end
136
+
137
+ def host_name(value)
138
+ strip_port(value).downcase.chomp(".")
139
+ end
140
+
141
+ def allowed_host?(host)
142
+ return false if host.empty?
143
+ return true if host == "localhost" || host.end_with?(".localhost")
144
+ return true if loopback_address?(host)
145
+ return true if test_environment? && TEST_HOSTS.include?(host)
146
+
147
+ permitted_by_rails_hosts?(host)
148
+ end
149
+
150
+ # When the application restricts config.hosts, ActionDispatch::HostAuthorization has
151
+ # already refused every other host; a host it lists is one the developer trusts.
152
+ def permitted_by_rails_hosts?(host)
153
+ return false unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
154
+ return false unless defined?(ActionDispatch::HostAuthorization::Permissions)
155
+
156
+ hosts = Rails.application.config.hosts
157
+ return false if hosts.nil? || hosts.empty?
158
+
159
+ ActionDispatch::HostAuthorization::Permissions.new(hosts).allows?(host)
160
+ end
161
+ end
162
+ end
163
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../slave_support"
4
4
  require_relative "../../env_override_store"
5
+ require_relative "../../redaction"
5
6
 
6
7
  module Profiler
7
8
  module MCP
@@ -19,8 +20,8 @@ module Profiler
19
20
  filter = params["filter"]&.downcase
20
21
 
21
22
  if include_all
22
- vars = ENV.to_h.sort.to_h
23
- overrides = Profiler.env_override_store.all_overrides
23
+ vars = Profiler::Redaction.env_snapshot
24
+ overrides = Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)
24
25
 
25
26
  vars = vars.select { |k, _| k.downcase.include?(filter) } if filter
26
27
 
@@ -39,7 +40,7 @@ module Profiler
39
40
  text += "|-----|--------------|------------|\n"
40
41
  text += rows.join("\n")
41
42
  else
42
- overrides = Profiler.env_override_store.all_overrides
43
+ overrides = Profiler::Redaction.env_overrides(Profiler.env_override_store.all_overrides)
43
44
  overrides = overrides.select { |k, _| k.downcase.include?(filter) } if filter
44
45
 
45
46
  if overrides.empty?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../slave_support"
4
+ require_relative "../../redaction"
4
5
 
5
6
  require "uri"
6
7
 
@@ -26,7 +27,7 @@ module Profiler
26
27
  original = overrides[key]["original"]
27
28
  Profiler.env_override_store.reset(key)
28
29
 
29
- restored = original || "(unset)"
30
+ restored = original.nil? ? "(unset)" : Profiler::Redaction.env_value(key, original)
30
31
  [{ type: "text", text: "Reset #{key} to original value: #{restored}" }]
31
32
  end
32
33
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../slave_support"
4
+ require_relative "../../redaction"
4
5
 
5
6
  module Profiler
6
7
  module MCP
@@ -11,6 +12,10 @@ module Profiler
11
12
  value = params["value"].to_s
12
13
 
13
14
  return [{ type: "text", text: "Error: key cannot be blank." }] if key.empty?
15
+ if Profiler::Redaction.mask?(value)
16
+ return [{ type: "text", text: "Error: #{Profiler::Redaction::MASK} is the mask the profiler shows " \
17
+ "in place of a hidden value, not a value; #{key} was left unchanged." }]
18
+ end
14
19
 
15
20
  if (proxy = MCP::SlaveSupport.with_slave_proxy(params))
16
21
  proxy.patch_json("/_profiler/api/env_vars", { key: key, value: value })
@@ -2,60 +2,62 @@
2
2
 
3
3
  module Profiler
4
4
  module Middleware
5
+ # Sets the framing headers of every profiler response, and its CORS headers when
6
+ # extension_cors_enabled is set. Both settings are read on each request.
5
7
  class CorsMiddleware
6
8
  def initialize(app)
7
9
  @app = app
8
10
  end
9
11
 
10
12
  def call(env)
11
- # Apply CORS and frame options to all profiler endpoints
12
- if env['PATH_INFO'].start_with?('/_profiler/')
13
- # Handle OPTIONS preflight request
14
- if env['REQUEST_METHOD'] == 'OPTIONS'
15
- return [
16
- 200,
17
- cors_headers(env),
18
- ['']
19
- ]
20
- end
21
-
22
- status, headers, body = @app.call(env)
23
- headers = headers.dup
24
-
25
- # Add CORS headers
26
- cors_headers(env).each do |key, value|
27
- headers[key] = value
28
- end
29
-
30
- # Allow embedding in iframes from Chrome extensions
31
- headers.delete('X-Frame-Options')
32
- # Don't set CSP if controller requested to skip it
33
- unless env['profiler.skip_csp']
34
- # Allow embedding from standard web origins
35
- headers['Content-Security-Policy'] = "frame-ancestors 'self' http: https:"
36
- end
37
-
38
- [status, headers, body]
39
- else
40
- @app.call(env)
13
+ return @app.call(env) unless profiler_path?(env['PATH_INFO'].to_s)
14
+
15
+ cors = Profiler.configuration.extension_cors_enabled
16
+
17
+ # Handle OPTIONS preflight request
18
+ if cors && env['REQUEST_METHOD'] == 'OPTIONS'
19
+ return [200, cors_headers(env), ['']]
41
20
  end
21
+
22
+ status, headers, body = @app.call(env)
23
+ headers = headers.dup
24
+
25
+ cors_headers(env).each { |key, value| set_header(headers, key, value) } if cors
26
+
27
+ # frame-ancestors decides in every browser that knows it, which then ignores
28
+ # X-Frame-Options; SAMEORIGIN only protects older browsers.
29
+ set_header(headers, 'X-Frame-Options', 'SAMEORIGIN')
30
+ set_header(headers, 'Content-Security-Policy', "frame-ancestors #{frame_ancestors}")
31
+
32
+ [status, headers, body]
42
33
  end
43
34
 
44
35
  private
45
36
 
37
+ # "/_profiler" itself is the dashboard URL; "/_profilerfoo" belongs to the application.
38
+ def profiler_path?(path)
39
+ path == '/_profiler' || path.start_with?('/_profiler/')
40
+ end
41
+
42
+ def frame_ancestors
43
+ sources = Array(Profiler.configuration.frame_ancestors).map(&:to_s).reject(&:empty?)
44
+ sources.empty? ? "'none'" : sources.join(' ')
45
+ end
46
+
46
47
  def cors_headers(env)
47
- allowed_origins = Profiler.configuration.cors_allowed_origins
48
+ allowed_origins = Array(Profiler.configuration.cors_allowed_origins)
48
49
  request_origin = env['HTTP_ORIGIN']
49
50
 
50
- if allowed_origins.include?('*')
51
- origin_header = '*'
52
- elsif request_origin && allowed_origins.include?(request_origin)
51
+ if request_origin && allowed_origins.include?(request_origin)
53
52
  origin_header = request_origin
53
+ elsif allowed_origins.include?('*') && !credentialed?(env)
54
+ # Never on a request carrying credentials: they may be what authorizes it.
55
+ origin_header = '*'
54
56
  end
55
57
 
56
58
  headers = {
57
59
  'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
58
- 'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With, Accept',
60
+ 'Access-Control-Allow-Headers' => "Content-Type, X-Requested-With, Accept, #{Profiler::FORGERY_PROTECTION_HEADER}",
59
61
  'Access-Control-Expose-Headers' => 'X-Profiler-Token'
60
62
  }
61
63
 
@@ -66,6 +68,16 @@ module Profiler
66
68
 
67
69
  headers
68
70
  end
71
+
72
+ def credentialed?(env)
73
+ !env['HTTP_COOKIE'].to_s.empty? || !env['HTTP_AUTHORIZATION'].to_s.empty?
74
+ end
75
+
76
+ # Replaces a header whatever the case of its existing key, plain Hash or Rack::Headers.
77
+ def set_header(headers, name, value)
78
+ headers.keys.each { |key| headers.delete(key) if key.to_s.casecmp?(name) }
79
+ headers[name] = value
80
+ end
69
81
  end
70
82
  end
71
83
  end
@@ -4,6 +4,7 @@ require "base64"
4
4
  require "securerandom"
5
5
  require "json"
6
6
  require "zlib"
7
+ require_relative "../redaction"
7
8
 
8
9
  module Profiler
9
10
  module Models
@@ -46,7 +47,7 @@ module Profiler
46
47
  @finished_at = Time.now
47
48
  @duration = ((@finished_at - @started_at) * 1000).round(2) # milliseconds
48
49
  @status = status
49
- @response_headers = response_headers
50
+ @response_headers = Redaction.filter_headers(response_headers)
50
51
  end
51
52
 
52
53
  def add_collector_data(name, data)
@@ -161,7 +162,7 @@ module Profiler
161
162
  if binary_content_type?(content_type)
162
163
  { body: Base64.strict_encode64(raw.b), encoding: "base64" }
163
164
  else
164
- text = raw.encode("UTF-8", invalid: :replace, undef: :replace)
165
+ text = Redaction.filter_body(raw, content_type).encode("UTF-8", invalid: :replace, undef: :replace)
165
166
  if compress_body?(text)
166
167
  { body: Base64.strict_encode64(Zlib::Deflate.deflate(text)), encoding: "gzip+base64" }
167
168
  else
@@ -189,10 +190,14 @@ module Profiler
189
190
  ct.to_s.match?(%r{image/(?!svg)|application/(?:pdf|octet-stream|zip)|audio/|video/})
190
191
  end
191
192
 
193
+ LEGACY_FILTERED_PARAMS = %w[password password_confirmation token secret].freeze
194
+
192
195
  def sanitize_params(params)
193
196
  return {} unless params
194
197
 
195
- params.to_h.except("password", "password_confirmation", "token", "secret")
198
+ return params.to_h.except(*LEGACY_FILTERED_PARAMS) unless Redaction.enabled?
199
+
200
+ Redaction.filter_hash(params.to_h)
196
201
  end
197
202
 
198
203
  ALLOWED_HEADERS = %w[
@@ -206,6 +211,7 @@ module Profiler
206
211
  env.select { |k, _| k.start_with?("HTTP_") }
207
212
  .transform_keys { |k| k.sub(/^HTTP_/, "").split("_").map(&:capitalize).join("-") }
208
213
  .select { |k, _| ALLOWED_HEADERS.include?(k) }
214
+ .then { |headers| Redaction.filter_headers(headers) }
209
215
  end
210
216
  end
211
217
  end
@@ -24,11 +24,10 @@ module Profiler
24
24
  if Profiler.configuration.enabled
25
25
  require_relative "middleware/profiler_middleware"
26
26
 
27
- # Insert CORS middleware first if enabled
28
- if Profiler.configuration.extension_cors_enabled
29
- require_relative "middleware/cors_middleware"
30
- app.middleware.insert_before 0, Profiler::Middleware::CorsMiddleware
31
- end
27
+ # Always inserted: it sets the framing headers of every profiler response, and its
28
+ # CORS headers only when extension_cors_enabled is set.
29
+ require_relative "middleware/cors_middleware"
30
+ app.middleware.insert_before 0, Profiler::Middleware::CorsMiddleware
32
31
 
33
32
  app.middleware.insert_before 0, Profiler::Middleware::ProfilerMiddleware
34
33
  end