rails-profiler 0.31.6 → 0.32.0
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 +19 -0
- data/lib/profiler/configuration.rb +34 -1
- data/lib/profiler/local_request.rb +12 -11
- data/lib/profiler/mcp/http_guard.rb +84 -9
- data/lib/profiler/mcp/server.rb +10 -1
- data/lib/profiler/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 623e8531db783ddb27296b83dba2beae12f411e6b2c71630c3b14003b7e67f40
|
|
4
|
+
data.tar.gz: eebbf3cda8334b412ca1a4543cb81f7ef05e65c0f28672794fc9f0b7b57a250f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27241cbff0c9d09418f1d75a6757a55dba4b6474b822860f11e98b5149d2dda4b7fe7ad13a03e136bf9c1ca56dbd9fbdf4366dc394fa7127d3d9bba69cf23944
|
|
7
|
+
data.tar.gz: 25ad634237aecccf4a698f3ba7f221afbc53965a5b3a18845336c2ce8fef257f1d7fc8234b40545153fe0a9ba7b28fae92f52708994a50d4e81063d0a789116b
|
data/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,25 @@ every commit of every tag interval is accounted for one way or the other.
|
|
|
19
19
|
|
|
20
20
|
## [Unreleased]
|
|
21
21
|
|
|
22
|
+
## [0.32.0] - 2026-10-06
|
|
23
|
+
|
|
24
|
+
<!-- stamped -->
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **MCP:** The HTTP endpoint at `/_profiler/mcp` accepts the `Host` headers of a reverse proxy
|
|
29
|
+
without a monkey-patch: besides the loopback names, it now accepts the hosts the application lists
|
|
30
|
+
in `config.hosts` (when that list is not empty), so an application behind Traefik usually has
|
|
31
|
+
nothing to set, and the entries of the new `config.mcp_allowed_hosts` (default `[]`): a host name
|
|
32
|
+
or `host:port`, or a `Regexp` anchored with `\A` and `\z`, matched against the whole host name,
|
|
33
|
+
for example `["api.myapp.test", %r{\Amyapp[a-z0-9-]*\z}]`. With mcp 1.x the
|
|
34
|
+
transport only accepted the loopback names and answered `Forbidden: Invalid Host header` behind
|
|
35
|
+
a proxy. The profiler now makes the check itself, before the transport, for every kind of entry,
|
|
36
|
+
and keeps the transport's `Origin` check (same origin or `cors_allowed_origins`), whatever
|
|
37
|
+
`api_forgery_protection` says; the transport's own check, which only knows exact names, is turned
|
|
38
|
+
off. A `Regexp` that is not anchored, or that cannot match a whole host name, and a String
|
|
39
|
+
holding `*` raise an `ArgumentError` when assigned. The defaults keep the previous behaviour.
|
|
40
|
+
|
|
22
41
|
## [0.31.6] - 2026-10-05
|
|
23
42
|
|
|
24
43
|
<!-- stamped -->
|
|
@@ -132,6 +132,7 @@ module Profiler
|
|
|
132
132
|
@cluster_secret = nil
|
|
133
133
|
@cluster_require_secret = true
|
|
134
134
|
@cluster_allowed_slave_urls = []
|
|
135
|
+
@mcp_allowed_hosts = []
|
|
135
136
|
@cluster_allow_insecure_http = false
|
|
136
137
|
# Where the profiler's own errors go (Profiler.log): Rails.logger when nil, or $stderr when
|
|
137
138
|
# there is none.
|
|
@@ -147,7 +148,16 @@ module Profiler
|
|
|
147
148
|
@tmp_path = value.nil? ? nil : Pathname.new(value)
|
|
148
149
|
end
|
|
149
150
|
|
|
150
|
-
attr_reader :cluster_allowed_slave_urls
|
|
151
|
+
attr_reader :cluster_allowed_slave_urls, :mcp_allowed_hosts
|
|
152
|
+
|
|
153
|
+
# Host names the MCP HTTP endpoint accepts beyond the loopback ones and config.hosts: a String
|
|
154
|
+
# (a host name, any port, or a host:port) or a Regexp anchored with \A and \z, matched
|
|
155
|
+
# against the host name without its port. A wildcard String is refused.
|
|
156
|
+
def mcp_allowed_hosts=(value)
|
|
157
|
+
require_relative "cluster/security"
|
|
158
|
+
Array(value).each { |entry| validate_mcp_allowed_host(entry) }
|
|
159
|
+
@mcp_allowed_hosts = Array(value)
|
|
160
|
+
end
|
|
151
161
|
|
|
152
162
|
# Deprecated: the figure it was meant for was never a byte count (see AllocationCounter).
|
|
153
163
|
# Read and written as allocated_objects_warning_threshold times the bytes per object the
|
|
@@ -300,5 +310,28 @@ module Profiler
|
|
|
300
310
|
raise Error, "Unknown storage backend: #{@storage}"
|
|
301
311
|
end
|
|
302
312
|
end
|
|
313
|
+
|
|
314
|
+
def validate_mcp_allowed_host(entry)
|
|
315
|
+
case entry
|
|
316
|
+
when String
|
|
317
|
+
return unless entry.strip.empty? || entry.include?("*")
|
|
318
|
+
|
|
319
|
+
raise ArgumentError, "config.mcp_allowed_hosts: #{entry.inspect} is not a host name; list each host, " \
|
|
320
|
+
"or use an anchored Regexp"
|
|
321
|
+
when Regexp then validate_mcp_allowed_host_pattern(entry)
|
|
322
|
+
else raise ArgumentError, "config.mcp_allowed_hosts: #{entry.inspect} must be a String or a Regexp"
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def validate_mcp_allowed_host_pattern(pattern)
|
|
327
|
+
unless Profiler::Cluster::Security.anchored_pattern?(pattern)
|
|
328
|
+
raise ArgumentError, "config.mcp_allowed_hosts: #{pattern.inspect} must start with \\A and end with \\z, " \
|
|
329
|
+
"so that it matches the whole host name"
|
|
330
|
+
end
|
|
331
|
+
Profiler::Cluster::Security.whole_url_pattern(pattern)
|
|
332
|
+
rescue RegexpError => e
|
|
333
|
+
raise ArgumentError, "config.mcp_allowed_hosts: #{pattern.inspect} cannot be matched against the whole " \
|
|
334
|
+
"host name (#{e.message})"
|
|
335
|
+
end
|
|
303
336
|
end
|
|
304
337
|
end
|
|
@@ -85,6 +85,18 @@ module Profiler
|
|
|
85
85
|
false
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# When the application restricts config.hosts, ActionDispatch::HostAuthorization has
|
|
89
|
+
# already refused every other host; a host it lists is one the developer trusts.
|
|
90
|
+
def permitted_by_rails_hosts?(host)
|
|
91
|
+
return false unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
92
|
+
return false unless defined?(ActionDispatch::HostAuthorization::Permissions)
|
|
93
|
+
|
|
94
|
+
hosts = Rails.application.config.hosts
|
|
95
|
+
return false if hosts.nil? || hosts.empty?
|
|
96
|
+
|
|
97
|
+
ActionDispatch::HostAuthorization::Permissions.new(hosts).allows?(host)
|
|
98
|
+
end
|
|
99
|
+
|
|
88
100
|
private
|
|
89
101
|
|
|
90
102
|
def host_denial_reason(request)
|
|
@@ -143,17 +155,6 @@ module Profiler
|
|
|
143
155
|
permitted_by_rails_hosts?(host)
|
|
144
156
|
end
|
|
145
157
|
|
|
146
|
-
# When the application restricts config.hosts, ActionDispatch::HostAuthorization has
|
|
147
|
-
# already refused every other host; a host it lists is one the developer trusts.
|
|
148
|
-
def permitted_by_rails_hosts?(host)
|
|
149
|
-
return false unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
150
|
-
return false unless defined?(ActionDispatch::HostAuthorization::Permissions)
|
|
151
|
-
|
|
152
|
-
hosts = Rails.application.config.hosts
|
|
153
|
-
return false if hosts.nil? || hosts.empty?
|
|
154
|
-
|
|
155
|
-
ActionDispatch::HostAuthorization::Permissions.new(hosts).allows?(host)
|
|
156
|
-
end
|
|
157
158
|
end
|
|
158
159
|
end
|
|
159
160
|
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "uri"
|
|
5
|
+
require_relative "../cluster/security"
|
|
6
|
+
require_relative "../local_request"
|
|
5
7
|
|
|
6
8
|
module Profiler
|
|
7
9
|
module MCP
|
|
@@ -25,20 +27,86 @@ module Profiler
|
|
|
25
27
|
return deny("Profiler is disabled") unless config.enabled
|
|
26
28
|
|
|
27
29
|
request = ActionDispatch::Request.new(env)
|
|
30
|
+
if (host_denial = host_denial(request, config))
|
|
31
|
+
return deny(host_denial)
|
|
32
|
+
end
|
|
28
33
|
return deny("Not authorized to access the profiler") unless config.authorized?(request)
|
|
34
|
+
if (origin_denial = origin_denial(request, config))
|
|
35
|
+
return deny(origin_denial)
|
|
36
|
+
end
|
|
29
37
|
return nil unless config.api_forgery_protection
|
|
30
38
|
|
|
31
39
|
forgery_denial(request, config)
|
|
32
40
|
end
|
|
33
41
|
|
|
42
|
+
# The DNS rebinding check of the MCP transport, done here so that one rule covers every
|
|
43
|
+
# host: the transport itself only knows exact names, and is created without its own check
|
|
44
|
+
# (see Profiler::MCP::Server#http_transport). A Host header is accepted when it names a
|
|
45
|
+
# loopback address, a host the application lists in config.hosts (when that list is not
|
|
46
|
+
# empty, Rails' HostAuthorization already refuses every other host), or an entry of
|
|
47
|
+
# config.mcp_allowed_hosts. A request without a Host header (HTTP/1.0) is let through, as
|
|
48
|
+
# the transport did: the rebinding vector always carries one.
|
|
49
|
+
def allowed_host?(raw_host, config = Profiler.configuration)
|
|
50
|
+
return true if raw_host.nil?
|
|
51
|
+
|
|
52
|
+
full = raw_host.to_s.strip.downcase
|
|
53
|
+
name = host_name(full)
|
|
54
|
+
return false if name.empty?
|
|
55
|
+
return true if LOOPBACK_HOSTS.include?(name)
|
|
56
|
+
return true if Array(config.mcp_allowed_hosts).any? { |entry| host_entry_allows?(entry, name, full) }
|
|
57
|
+
|
|
58
|
+
# Rails matches config.hosts against the whole Host header (an entry may carry a port);
|
|
59
|
+
# the bare name covers entries written without one.
|
|
60
|
+
Profiler::LocalRequest.permitted_by_rails_hosts?(full) ||
|
|
61
|
+
Profiler::LocalRequest.permitted_by_rails_hosts?(name)
|
|
62
|
+
end
|
|
63
|
+
|
|
34
64
|
private
|
|
35
65
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
66
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
67
|
+
|
|
68
|
+
def host_denial(request, config)
|
|
69
|
+
raw = request.get_header("HTTP_HOST")
|
|
70
|
+
return nil if allowed_host?(raw, config)
|
|
71
|
+
|
|
72
|
+
"Host #{raw} is not allowed for the MCP endpoint: list it in config.hosts or " \
|
|
73
|
+
"config.mcp_allowed_hosts"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A String matches the host name (any port) or the whole host:port, case-insensitively; a
|
|
77
|
+
# Regexp must match the whole host name, without the port.
|
|
78
|
+
def host_entry_allows?(entry, name, full)
|
|
79
|
+
case entry
|
|
80
|
+
when Regexp
|
|
81
|
+
Profiler::Cluster::Security.anchored_pattern?(entry) &&
|
|
82
|
+
Profiler::Cluster::Security.whole_url_pattern(entry).match?(name)
|
|
83
|
+
when String
|
|
84
|
+
allowed = entry.strip.downcase
|
|
85
|
+
allowed == name || allowed == full
|
|
86
|
+
else
|
|
87
|
+
false
|
|
40
88
|
end
|
|
89
|
+
rescue RegexpError
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# "[::1]:3000" gives "::1", "app.local:3000" gives "app.local". Anything else (two hosts, a
|
|
94
|
+
# port that is not a number, text after the brackets) gives "", which is refused.
|
|
95
|
+
def host_name(host)
|
|
96
|
+
match = host.match(/\A\[([^\]]+)\](?::\d+)?\z/) || host.match(/\A([^:\s,]+)(?::\d+)?\z/)
|
|
97
|
+
match ? match[1] : ""
|
|
98
|
+
end
|
|
41
99
|
|
|
100
|
+
# The Origin check of the transport, kept whatever api_forgery_protection says: a
|
|
101
|
+
# browser's Origin must be the profiler's own or one of cors_allowed_origins.
|
|
102
|
+
def origin_denial(request, config)
|
|
103
|
+
origin = request.get_header("HTTP_ORIGIN")
|
|
104
|
+
return nil if origin.nil? || origin.empty? || allowed_origin?(origin, request, config)
|
|
105
|
+
|
|
106
|
+
"Cross-origin request refused: Origin #{origin} is not allowed"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def forgery_denial(request, _config)
|
|
42
110
|
if request.post? && media_type(request) != JSON_MEDIA_TYPE
|
|
43
111
|
return deny("A POST to the MCP endpoint needs Content-Type: #{JSON_MEDIA_TYPE}")
|
|
44
112
|
end
|
|
@@ -53,13 +121,20 @@ module Profiler
|
|
|
53
121
|
def allowed_origin?(origin, request, config)
|
|
54
122
|
return true if Array(config.cors_allowed_origins).include?(origin)
|
|
55
123
|
|
|
56
|
-
same_origin?(origin, request
|
|
124
|
+
same_origin?(origin, request)
|
|
57
125
|
end
|
|
58
126
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
127
|
+
# The Origin's host and port against the Host header, as the transport compares them: the
|
|
128
|
+
# scheme is left out, since behind a proxy that ends TLS the request says http while the
|
|
129
|
+
# browser's Origin says https, and the default port of the Origin's scheme is dropped.
|
|
130
|
+
def same_origin?(origin, request)
|
|
131
|
+
uri = URI.parse(origin)
|
|
132
|
+
host = request.get_header("HTTP_HOST").to_s.downcase
|
|
133
|
+
return false if uri.host.nil? || host.empty?
|
|
134
|
+
|
|
135
|
+
authority = uri.port == uri.default_port ? uri.host.downcase : "#{uri.host.downcase}:#{uri.port}"
|
|
136
|
+
host = host.delete_suffix(":#{uri.default_port}")
|
|
137
|
+
authority == host
|
|
63
138
|
rescue URI::InvalidURIError
|
|
64
139
|
false
|
|
65
140
|
end
|
data/lib/profiler/mcp/server.rb
CHANGED
|
@@ -46,12 +46,21 @@ module Profiler
|
|
|
46
46
|
|
|
47
47
|
def http_transport
|
|
48
48
|
@http_transport ||= begin
|
|
49
|
-
t = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server)
|
|
49
|
+
t = ::MCP::Server::Transports::StreamableHTTPTransport.new(@server, **http_transport_options)
|
|
50
50
|
@server.transport = t
|
|
51
51
|
t
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# HttpGuard checks the Host and Origin headers of every request before the transport sees
|
|
56
|
+
# it, with config.hosts, config.mcp_allowed_hosts and Regexp entries, which the transport's
|
|
57
|
+
# own check (exact names only) cannot express. Older mcp versions have no such check, nor
|
|
58
|
+
# the option.
|
|
59
|
+
def http_transport_options
|
|
60
|
+
params = ::MCP::Server::Transports::StreamableHTTPTransport.instance_method(:initialize).parameters
|
|
61
|
+
params.any? { |_type, name| name == :dns_rebinding_protection } ? { dns_rebinding_protection: false } : {}
|
|
62
|
+
end
|
|
63
|
+
|
|
55
64
|
def start(transport: :stdio)
|
|
56
65
|
case transport
|
|
57
66
|
when :stdio
|
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.
|
|
4
|
+
version: 0.32.0
|
|
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-06 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|