rails-profiler 0.30.5 → 0.30.6

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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/app/assets/builds/profiler-toolbar.js +2 -1
  4. data/app/assets/builds/profiler.js +2 -1
  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 +0 -2
  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/configuration.rb +22 -4
  25. data/lib/profiler/local_request.rb +163 -0
  26. data/lib/profiler/middleware/cors_middleware.rb +47 -35
  27. data/lib/profiler/railtie.rb +4 -5
  28. data/lib/profiler/version.rb +1 -1
  29. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04defc47e71c610492ff23fbce13ebf669f459a1cac74c5933200bdf4d52365c
4
- data.tar.gz: d99cf7c5531fd369aa8c9367d95b0a5314f71f38de6ed64a7c81a9693f69566a
3
+ metadata.gz: e7a2ea61f4463670763c8c3c2d92f98a1f4ff2917fb9a2f5e0d245ea1860d2f1
4
+ data.tar.gz: ba467072b67f027565a29c99f458cd959933de512cdfa054ddde052d3e65ddb1
5
5
  SHA512:
6
- metadata.gz: '09ba67258286ddc275d2d1cc36234dd8f0e7d24783034dd3959c666b41d96bb334d5c38d720ceca2254d205449094aa57cac9de6afb1842e3437fbd2d238eac3'
7
- data.tar.gz: '0936d7ca150be6a80809739a2d9b94f77c51c80fad8796bb01d93fb2ca0047a5da3eb4ae832f9567c381bd3dcaacad3000b0273664362570306060c8b3b3eeec'
6
+ metadata.gz: b2a1049428bab86d06ba54bba7cd373bfc3ebe606137bd2e6bfda46d5b1501c10574ebcec632125efd9e5c6b6a0be2d11c763b15e670a06aaaac384c0e028c6e
7
+ data.tar.gz: 561590eb7eadc20139ea79a0005efefbf9de88da0010db4ae20903360d3199342ba9648fe9d6d4bc1452c62e18c61f47311f19bb78f68a3307791f3089a11f06
data/CHANGELOG.md CHANGED
@@ -19,6 +19,49 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.6] - 2026-10-03
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Security
27
+
28
+ - **Access control:** Check `authorization_mode` on every page and endpoint of the profiler (UI,
29
+ API, server-sent events, test runner, toolbar), answering `403` otherwise. It used to decide only
30
+ which requests were captured: with `:allow_authorized` and a block that refused everybody, the API
31
+ still listed and deleted profiles and wrote `ENV`. The gem's static JS and CSS stay public.
32
+ - **Access control:** The new default `authorization_mode`, `:allow_local`, only lets in requests
33
+ made from this machine: a loopback `REMOTE_ADDR`, no forwarding header naming a remote client, and
34
+ a local `Host` (or one listed in `config.hosts`), against DNS rebinding; the test environment
35
+ also accepts the reserved hosts `www.example.com`, `example.com` and `example.org`, so the
36
+ application's request specs are still captured. It also decides
37
+ which requests are captured, and logs the reason for a refusal once per process. The previous
38
+ default, `:allow_all`, let anybody who could reach the application read and change everything.
39
+ - **Access control:** API requests that change something (including a form `POST` turned into
40
+ another verb by `_method`) must carry an `X-Profiler-Request` header or a CSRF token, so that a
41
+ page on another site can no longer trigger them, even when the application turns
42
+ `allow_forgery_protection` off. The dashboard, the toolbar and the cluster send the header.
43
+ - **Access control:** CORS is off by default (`extension_cors_enabled = false`,
44
+ `cors_allowed_origins = []`), and `Access-Control-Allow-Origin: *` is never sent to a request
45
+ carrying a cookie or an `Authorization` header. It used to be `*` for everybody.
46
+ - **Access control:** Profiler pages may only be framed by the profiler itself and by the Chrome
47
+ extension's DevTools panel (`frame-ancestors 'self' chrome-extension: devtools:`, configurable
48
+ with the new `frame_ancestors` option), and send `X-Frame-Options: SAMEORIGIN`. Any website could
49
+ frame them, and with `?embed=true` a profile page carried no framing protection at all.
50
+ - **Upgrading:** Nothing to do when you browse the profiler from the machine that runs Rails. Each
51
+ previous behavior can be restored in `config/initializers/profiler.rb`:
52
+ - Docker, a VM or a remote proxy: admit your network with `config.authorization_mode =
53
+ :allow_authorized` and an `authorize_with` block reading `REMOTE_ADDR` (example in the README), or
54
+ go back to `config.authorization_mode = :allow_all`, which offers no protection.
55
+ - A cluster whose master and slaves run on different machines: each side admits the other's address
56
+ the same way.
57
+ - Your own scripts calling the API: send `X-Profiler-Request: 1`, or set
58
+ `config.api_forgery_protection = false`.
59
+ - Cross-origin clients: `config.extension_cors_enabled = true` and
60
+ `config.cors_allowed_origins = ["https://your.origin"]`. `["*"]` restores the behavior before
61
+ 0.30.6 and reopens the profiler to every website you visit: under `:allow_local` or `:allow_all`,
62
+ any page open in your browser can read its data and change it.
63
+ - Framing by other sites: `config.frame_ancestors = ["'self'", "http:", "https:"]`.
64
+
22
65
  ## [0.30.5] - 2026-10-03
23
66
 
24
67
  <!-- stamped -->
@@ -4167,10 +4167,11 @@
4167
4167
  const { method, params, data, headers = {}, signal } = config;
4168
4168
  const url = resolvedUrl;
4169
4169
  const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
4170
+ const baseHeaders = { "X-Profiler-Request": "1", ...headers };
4170
4171
  const res = await fetch(url + qs, {
4171
4172
  method,
4172
4173
  signal,
4173
- headers: data !== void 0 ? { "Content-Type": "application/json", ...headers } : headers,
4174
+ headers: data !== void 0 ? { "Content-Type": "application/json", ...baseHeaders } : baseHeaders,
4174
4175
  body: data !== void 0 ? JSON.stringify(data) : void 0
4175
4176
  });
4176
4177
  if (res.status === 204) return {};
@@ -3456,10 +3456,11 @@
3456
3456
  const { method, params, data, headers = {}, signal } = config;
3457
3457
  const url = resolvedUrl;
3458
3458
  const qs = params ? "?" + new URLSearchParams(Object.entries(params).map(([k3, v3]) => [k3, String(v3)])).toString() : "";
3459
+ const baseHeaders = { "X-Profiler-Request": "1", ...headers };
3459
3460
  const res = await fetch(url + qs, {
3460
3461
  method,
3461
3462
  signal,
3462
- headers: data !== void 0 ? { "Content-Type": "application/json", ...headers } : headers,
3463
+ headers: data !== void 0 ? { "Content-Type": "application/json", ...baseHeaders } : baseHeaders,
3463
3464
  body: data !== void 0 ? JSON.stringify(data) : void 0
3464
3465
  });
3465
3466
  if (res.status === 204) return {};
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class AjaxController < ApplicationController
6
- skip_before_action :verify_authenticity_token, only: [:link]
7
-
8
6
  def link
9
7
  parent_token = params[:parent_token]
10
8
  child_token = params[:child_token]
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ClusterController < Profiler::ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def register
9
7
  name = params[:name].to_s
10
8
  url = params[:url].to_s
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ConsoleController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def index
9
7
  limit = (params[:limit] || 50).to_i
10
8
  offset = (params[:offset] || 0).to_i
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class EnvVarsController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def show
9
7
  variables = ENV.to_h.sort.to_h
10
8
  overrides = Profiler.env_override_store.all_overrides
@@ -4,7 +4,6 @@ module Profiler
4
4
  module Api
5
5
  class EventsController < Profiler::ApplicationController
6
6
  include ActionController::Live
7
- skip_before_action :verify_authenticity_token
8
7
 
9
8
  def subscribe
10
9
  response.headers["Content-Type"] = "text/event-stream"
@@ -5,8 +5,6 @@ require "profiler/explain_runner"
5
5
  module Profiler
6
6
  module Api
7
7
  class ExplainController < ApplicationController
8
- skip_before_action :verify_authenticity_token
9
-
10
8
  def create
11
9
  unless Profiler.configuration.enabled
12
10
  return render json: { error: "EXPLAIN is only available when the profiler is enabled" }, status: :forbidden
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class FunctionProfilingController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def show
9
7
  render json: {
10
8
  enabled: Profiler.function_profiling_enabled,
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class JobsController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def index
9
7
  limit = (params[:limit] || 50).to_i
10
8
  offset = (params[:offset] || 0).to_i
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class OutboundHttpController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  # GET /_profiler/api/outbound_http
9
7
  def index
10
8
  limit = (params[:limit] || 200).to_i
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ProfilesController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def index
9
7
  limit = (params[:limit] || 50).to_i
10
8
  offset = (params[:offset] || 0).to_i
@@ -5,8 +5,6 @@ require_relative "../../../../lib/profiler/cluster/slave_proxy"
5
5
  module Profiler
6
6
  module Api
7
7
  class SlaveProxyController < Profiler::ApplicationController
8
- skip_before_action :verify_authenticity_token
9
-
10
8
  def forward
11
9
  proxy = Cluster::SlaveProxy.new(params[:slave_name])
12
10
  sub_path = params[:path].to_s
@@ -8,8 +8,6 @@ module Profiler
8
8
  class TestRunnerController < ApplicationController
9
9
  include ActionController::Live
10
10
 
11
- skip_before_action :verify_authenticity_token
12
-
13
11
  def files
14
12
  framework = params[:framework]
15
13
  tree = Profiler::TestRunner::Discovery.files(framework: framework)
@@ -3,8 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class TestsController < ApplicationController
6
- skip_before_action :verify_authenticity_token
7
-
8
6
  def index
9
7
  limit = (params[:limit] || 50).to_i
10
8
  offset = (params[:offset] || 0).to_i
@@ -3,7 +3,6 @@
3
3
  module Profiler
4
4
  module Api
5
5
  class ToolbarController < Profiler::ApplicationController
6
- skip_before_action :verify_authenticity_token
7
6
  skip_before_action :check_authorization
8
7
 
9
8
  def show
@@ -2,11 +2,12 @@
2
2
 
3
3
  module Profiler
4
4
  class ApplicationController < ActionController::Base
5
- protect_from_forgery with: :exception
6
-
7
5
  layout "profiler/application"
8
6
 
9
7
  before_action :check_authorization
8
+ before_action :authorize_request
9
+ # Declared after the access checks, so that an unauthorized request gets its 403 first.
10
+ protect_from_forgery with: :exception
10
11
 
11
12
  private
12
13
 
@@ -16,6 +17,36 @@ module Profiler
16
17
  end
17
18
  end
18
19
 
20
+ def authorize_request
21
+ return if Profiler.configuration.authorized?(request)
22
+
23
+ deny("Not authorized to access the profiler")
24
+ end
25
+
26
+ # Decided by the profiler's own setting, not by the application's allow_forgery_protection,
27
+ # which the config/environments/test.rb generated by Rails turns off.
28
+ def protect_against_forgery?
29
+ Profiler.configuration.api_forgery_protection ? true : false
30
+ end
31
+
32
+ # Rails' token, or the header every profiler client sends. A third-party page cannot add a
33
+ # custom header without a CORS preflight, which is refused unless an origin was allowed.
34
+ def verified_request?
35
+ super || request.headers[Profiler::FORGERY_PROTECTION_HEADER].present?
36
+ end
37
+
38
+ def handle_unverified_request
39
+ deny("Missing #{Profiler::FORGERY_PROTECTION_HEADER} header or CSRF token")
40
+ end
41
+
42
+ def deny(message)
43
+ if controller_path.start_with?("profiler/api/")
44
+ render json: { error: message }, status: :forbidden
45
+ else
46
+ render plain: message, status: :forbidden
47
+ end
48
+ end
49
+
19
50
  def build_child_jobs(profile)
20
51
  storage = @resolved_storage || Profiler.storage
21
52
  storage.find_by_parent(profile.token)
@@ -2,8 +2,11 @@
2
2
 
3
3
  module Profiler
4
4
  class AssetsController < ApplicationController
5
+ # The gem's own static JS and CSS: no application data, cached publicly. Every piece of
6
+ # data they display comes from the API, which keeps the authorization check.
5
7
  skip_before_action :verify_authenticity_token
6
8
  skip_before_action :check_authorization
9
+ skip_before_action :authorize_request
7
10
 
8
11
  def toolbar_js
9
12
  path = Profiler::Engine.root.join("app", "assets", "builds", "profiler-toolbar.js")
@@ -2,8 +2,6 @@
2
2
 
3
3
  module Profiler
4
4
  class ProfilesController < ApplicationController
5
- before_action :allow_iframe_embedding, only: [:show], if: -> { params[:embed] == "true" }
6
-
7
5
  def index
8
6
  limit = params[:limit]&.to_i || 50
9
7
  offset = params[:offset]&.to_i || 0
@@ -158,13 +156,6 @@ module Profiler
158
156
  end
159
157
  end
160
158
 
161
- def allow_iframe_embedding
162
- response.headers.delete('X-Frame-Options')
163
- # Don't set frame-ancestors CSP to allow Chrome extensions to embed
164
- # Mark that CSP should not be set by middleware
165
- request.env['profiler.skip_csp'] = true
166
- end
167
-
168
159
  def filter_profiles(profiles)
169
160
  filtered = profiles
170
161
 
@@ -24,7 +24,8 @@ module Profiler
24
24
  config = Profiler.configuration
25
25
  uri = URI("#{config.master_url}/_profiler/api/cluster/register")
26
26
  body = { name: config.resolved_name, url: config.self_url }.to_json
27
- resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json")
27
+ resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json",
28
+ Profiler::FORGERY_PROTECTION_HEADER => "1")
28
29
  unless resp.code.to_i.between?(200, 299)
29
30
  raise "Master returned #{resp.code}: #{resp.body.to_s.slice(0, 200)}"
30
31
  end
@@ -36,7 +37,8 @@ module Profiler
36
37
  config = Profiler.configuration
37
38
  uri = URI("#{config.master_url}/_profiler/api/cluster/heartbeat")
38
39
  body = { name: config.resolved_name }.to_json
39
- resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json")
40
+ resp = Net::HTTP.post(uri, body, "Content-Type" => "application/json",
41
+ Profiler::FORGERY_PROTECTION_HEADER => "1")
40
42
  raise "Heartbeat rejected #{resp.code}" unless resp.code.to_i.between?(200, 299)
41
43
  end
42
44
 
@@ -86,6 +86,7 @@ module Profiler
86
86
  end
87
87
 
88
88
  def request(uri, req)
89
+ req[Profiler::FORGERY_PROTECTION_HEADER] = "1"
89
90
  resp = Net::HTTP.start(uri.hostname, uri.port,
90
91
  open_timeout: @open_timeout, read_timeout: @read_timeout,
91
92
  use_ssl: uri.scheme == "https") do |http|
@@ -1,13 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "local_request"
4
+
3
5
  module Profiler
6
+ # Header the profiler's own clients send on every request. A page on another origin cannot
7
+ # add it without a CORS preflight, which the profiler does not grant by default, so its
8
+ # presence proves the request is not a cross-site forgery.
9
+ FORGERY_PROTECTION_HEADER = "X-Profiler-Request"
10
+
4
11
  class Configuration
5
12
  attr_accessor :enabled, :storage_options, :collectors,
6
13
  :skip_paths, :slow_query_threshold, :max_queries_warning,
7
14
  :track_memory, :memory_warning_threshold,
8
15
  :mcp_enabled, :mcp_transport, :mcp_port,
9
16
  :authorization_mode, :max_profiles, :extension_cors_enabled,
10
- :cors_allowed_origins,
17
+ :cors_allowed_origins, :api_forgery_protection, :frame_ancestors,
11
18
  :track_ajax, :ajax_skip_paths,
12
19
  :track_http, :slow_http_threshold, :http_skip_hosts, :http_backtrace_depth,
13
20
  :track_jobs,
@@ -35,11 +42,16 @@ module Profiler
35
42
  @mcp_enabled = false
36
43
  @mcp_transport = :stdio
37
44
  @mcp_port = 3001
38
- @authorization_mode = :allow_all
45
+ @authorization_mode = :allow_local
39
46
  @authorize_block = nil
40
47
  @max_profiles = 100
41
- @extension_cors_enabled = true
42
- @cors_allowed_origins = ["*"]
48
+ @extension_cors_enabled = false
49
+ @cors_allowed_origins = []
50
+ @api_forgery_protection = true
51
+ # The Chrome extension shows the profiler in a DevTools panel: an iframe inside a
52
+ # chrome-extension:// page, itself inside the devtools://devtools front end. Chrome
53
+ # checks frame-ancestors against every ancestor, so both schemes are needed.
54
+ @frame_ancestors = ["'self'", "chrome-extension:", "devtools:"]
43
55
  @track_ajax = true
44
56
  @ajax_skip_paths = [/^\/_profiler/]
45
57
  @track_http = true
@@ -87,6 +99,12 @@ module Profiler
87
99
  case @authorization_mode
88
100
  when :allow_all
89
101
  true
102
+ when :allow_local
103
+ reason = LocalRequest.denial_reason(request)
104
+ return true if reason.nil?
105
+
106
+ LocalRequest.warn_once(reason)
107
+ false
90
108
  when :allow_authorized
91
109
  @authorize_block ? @authorize_block.call(request) : false
92
110
  else
@@ -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,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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.5"
4
+ VERSION = "0.30.6"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.5
4
+ version: 0.30.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-03 00:00:00.000000000 Z
11
+ date: 2026-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -168,6 +168,7 @@ files:
168
168
  - lib/profiler/instrumentation/sidekiq_middleware.rb
169
169
  - lib/profiler/instrumentation/thread_context_propagation.rb
170
170
  - lib/profiler/job_profiler.rb
171
+ - lib/profiler/local_request.rb
171
172
  - lib/profiler/mcp/body_formatter.rb
172
173
  - lib/profiler/mcp/file_cache.rb
173
174
  - lib/profiler/mcp/path_extractor.rb