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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/app/assets/builds/profiler-toolbar.js +2 -1
- data/app/assets/builds/profiler.js +2 -1
- data/app/controllers/profiler/api/ajax_controller.rb +0 -2
- data/app/controllers/profiler/api/cluster_controller.rb +0 -2
- data/app/controllers/profiler/api/console_controller.rb +0 -2
- data/app/controllers/profiler/api/env_vars_controller.rb +0 -2
- data/app/controllers/profiler/api/events_controller.rb +0 -1
- data/app/controllers/profiler/api/explain_controller.rb +0 -2
- data/app/controllers/profiler/api/function_profiling_controller.rb +0 -2
- data/app/controllers/profiler/api/jobs_controller.rb +0 -2
- data/app/controllers/profiler/api/outbound_http_controller.rb +0 -2
- data/app/controllers/profiler/api/profiles_controller.rb +0 -2
- data/app/controllers/profiler/api/slave_proxy_controller.rb +0 -2
- data/app/controllers/profiler/api/test_runner_controller.rb +0 -2
- data/app/controllers/profiler/api/tests_controller.rb +0 -2
- data/app/controllers/profiler/api/toolbar_controller.rb +0 -1
- data/app/controllers/profiler/application_controller.rb +33 -2
- data/app/controllers/profiler/assets_controller.rb +3 -0
- data/app/controllers/profiler/profiles_controller.rb +0 -9
- data/lib/profiler/cluster/master_client.rb +4 -2
- data/lib/profiler/cluster/slave_proxy.rb +1 -0
- data/lib/profiler/configuration.rb +22 -4
- data/lib/profiler/local_request.rb +163 -0
- data/lib/profiler/middleware/cors_middleware.rb +47 -35
- data/lib/profiler/railtie.rb +4 -5
- data/lib/profiler/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7a2ea61f4463670763c8c3c2d92f98a1f4ff2917fb9a2f5e0d245ea1860d2f1
|
|
4
|
+
data.tar.gz: ba467072b67f027565a29c99f458cd959933de512cdfa054ddde052d3e65ddb1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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", ...
|
|
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", ...
|
|
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 {};
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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
|
|
|
@@ -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 = :
|
|
45
|
+
@authorization_mode = :allow_local
|
|
39
46
|
@authorize_block = nil
|
|
40
47
|
@max_profiles = 100
|
|
41
|
-
@extension_cors_enabled =
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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' =>
|
|
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
|
data/lib/profiler/railtie.rb
CHANGED
|
@@ -24,11 +24,10 @@ module Profiler
|
|
|
24
24
|
if Profiler.configuration.enabled
|
|
25
25
|
require_relative "middleware/profiler_middleware"
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
data/lib/profiler/version.rb
CHANGED
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.
|
|
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-
|
|
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
|