rails-profiler 0.31.6 → 0.32.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8c9d9f31f3f32dc5ada20eb78eb6bc205c0985e084942edf18b283cfafe81be
4
- data.tar.gz: 49fb9afa0dd2fa52033f151b077ec971db08fe45818fe64448559ba95f4face3
3
+ metadata.gz: 696b15a9e7e1409bea43ef0541724ca559955c2dba15643c5743bf606c62ba60
4
+ data.tar.gz: 4091f3101110253c4ff00c32a0010e8b8097c329b7b2af10e91f13df82a15dbe
5
5
  SHA512:
6
- metadata.gz: 79bd6095dd2deb184ee662c398183d5e63b9b9af30f5bda76b1806353adce5137627c2e33afc5c95aadc451dd540a9d243c1cc1df3eb72eccc2001331362581f
7
- data.tar.gz: bd488d9da999d2f54be42b4002030f88bb51b069c956b78c85f0aadc4e87faa3f6ec3a2bb26c9921b73bea0fbaa84e16fd8e3f5967cfc8276ada75fa44a9b331
6
+ metadata.gz: 101f907c26e725cafa2d4fb70cab3ae957e0e3db9ddca412220137ae66af242f8c8397646bb8c239999e6d5b4d6121bf1c1b73169fe3c8ce982f7fbf2dcd066c
7
+ data.tar.gz: f966f148cd6cbb34867691d0f18549190b15e5059e21b87adbdf457b5b62e8cceeeac195f0693fe206e9117440e940d0f96aac4a25c8c411cfc84bec28ec5fb5
data/CHANGELOG.md CHANGED
@@ -19,6 +19,37 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.32.1] - 2026-10-13
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Fixed
27
+
28
+ - **Packaging:** The published gem now contains its `LICENSE` file, which the MIT license asks
29
+ every copy to carry and which was missing from the gem up to 0.32.0, and a new
30
+ `THIRD-PARTY-NOTICES.md` that reproduces the copyright and license notices of the three MIT
31
+ packages compiled into the dashboard and toolbar JavaScript: `preact`, `@tanstack/preact-query`
32
+ and `@tanstack/query-core`. The license of the gem does not change, it stays MIT.
33
+
34
+ ## [0.32.0] - 2026-10-06
35
+
36
+ <!-- stamped -->
37
+
38
+ ### Added
39
+
40
+ - **MCP:** The HTTP endpoint at `/_profiler/mcp` accepts the `Host` headers of a reverse proxy
41
+ without a monkey-patch: besides the loopback names, it now accepts the hosts the application lists
42
+ in `config.hosts` (when that list is not empty), so an application behind Traefik usually has
43
+ nothing to set, and the entries of the new `config.mcp_allowed_hosts` (default `[]`): a host name
44
+ or `host:port`, or a `Regexp` anchored with `\A` and `\z`, matched against the whole host name,
45
+ for example `["api.myapp.test", %r{\Amyapp[a-z0-9-]*\z}]`. With mcp 1.x the
46
+ transport only accepted the loopback names and answered `Forbidden: Invalid Host header` behind
47
+ a proxy. The profiler now makes the check itself, before the transport, for every kind of entry,
48
+ and keeps the transport's `Origin` check (same origin or `cors_allowed_origins`), whatever
49
+ `api_forgery_protection` says; the transport's own check, which only knows exact names, is turned
50
+ off. A `Regexp` that is not anchored, or that cannot match a whole host name, and a String
51
+ holding `*` raise an `ArgumentError` when assigned. The defaults keep the previous behaviour.
52
+
22
53
  ## [0.31.6] - 2026-10-05
23
54
 
24
55
  <!-- stamped -->
@@ -388,7 +419,7 @@ every commit of every tag interval is accounted for one way or the other.
388
419
 
389
420
  - **Cluster:** `config.cluster_allowed_slave_urls` accepts `Regexp` entries next to URLs, for
390
421
  slaves whose names are not known in advance, such as one container per git worktree:
391
- `[%r{\Ahttp://travel-api-[a-z0-9-]+:3000\z}]`. Until now such a setup had to fall back to
422
+ `[%r{\Ahttp://myapp-[a-z0-9-]+:3000\z}]`. Until now such a setup had to fall back to
392
423
  `:any`, which reopens the server-side request forgery closed in 0.30.11. A pattern must start
393
424
  with `\A` and end with `\z`, and compile once wrapped to match the whole URL; any other `Regexp`
394
425
  raises an `ArgumentError` when assigned. It is matched as a whole against the URL as the master
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sébastien Duplessy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,92 @@
1
+ # Third-party notices
2
+
3
+ The compiled JavaScript shipped in this gem (`app/assets/builds/profiler.js` and
4
+ `app/assets/builds/profiler-toolbar.js`) embeds the following third-party packages. Each one is
5
+ distributed under the MIT License; its copyright and permission notice is reproduced below, as that
6
+ license requires.
7
+
8
+ The license of this gem itself is in the `LICENSE` file.
9
+
10
+ ## preact 10.29.0
11
+
12
+ - License: MIT
13
+
14
+ ```text
15
+ The MIT License (MIT)
16
+
17
+ Copyright (c) 2015-present Jason Miller
18
+
19
+ Permission is hereby granted, free of charge, to any person obtaining a copy
20
+ of this software and associated documentation files (the "Software"), to deal
21
+ in the Software without restriction, including without limitation the rights
22
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
23
+ copies of the Software, and to permit persons to whom the Software is
24
+ furnished to do so, subject to the following conditions:
25
+
26
+ The above copyright notice and this permission notice shall be included in all
27
+ copies or substantial portions of the Software.
28
+
29
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
30
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
31
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
32
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
33
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
34
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
35
+ SOFTWARE.
36
+ ```
37
+
38
+ ## @tanstack/preact-query 5.101.0
39
+
40
+ - License: MIT
41
+
42
+ ```text
43
+ MIT License
44
+
45
+ Copyright (c) 2021-present Tanner Linsley
46
+
47
+ Permission is hereby granted, free of charge, to any person obtaining a copy
48
+ of this software and associated documentation files (the "Software"), to deal
49
+ in the Software without restriction, including without limitation the rights
50
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
51
+ copies of the Software, and to permit persons to whom the Software is
52
+ furnished to do so, subject to the following conditions:
53
+
54
+ The above copyright notice and this permission notice shall be included in all
55
+ copies or substantial portions of the Software.
56
+
57
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
58
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
59
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
60
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
61
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
62
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
63
+ SOFTWARE.
64
+ ```
65
+
66
+ ## @tanstack/query-core 5.101.0
67
+
68
+ - License: MIT
69
+
70
+ ```text
71
+ MIT License
72
+
73
+ Copyright (c) 2021-present Tanner Linsley
74
+
75
+ Permission is hereby granted, free of charge, to any person obtaining a copy
76
+ of this software and associated documentation files (the "Software"), to deal
77
+ in the Software without restriction, including without limitation the rights
78
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
79
+ copies of the Software, and to permit persons to whom the Software is
80
+ furnished to do so, subject to the following conditions:
81
+
82
+ The above copyright notice and this permission notice shall be included in all
83
+ copies or substantial portions of the Software.
84
+
85
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
86
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
87
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
88
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
89
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
90
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
91
+ SOFTWARE.
92
+ ```
@@ -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
- def forgery_denial(request, config)
37
- origin = request.get_header("HTTP_ORIGIN")
38
- if origin && !origin.empty? && !allowed_origin?(origin, request, config)
39
- return deny("Cross-origin request refused: Origin #{origin} is not allowed")
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.base_url)
124
+ same_origin?(origin, request)
57
125
  end
58
126
 
59
- def same_origin?(origin, base_url)
60
- a = URI.parse(origin)
61
- b = URI.parse(base_url)
62
- a.scheme.to_s.casecmp?(b.scheme.to_s) && a.host.to_s.casecmp?(b.host.to_s) && a.port == b.port
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.31.6"
4
+ VERSION = "0.32.1"
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.31.6
4
+ version: 0.32.1
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-05 00:00:00.000000000 Z
11
+ date: 2026-10-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -104,6 +104,8 @@ extensions: []
104
104
  extra_rdoc_files: []
105
105
  files:
106
106
  - CHANGELOG.md
107
+ - LICENSE
108
+ - THIRD-PARTY-NOTICES.md
107
109
  - app/assets/builds/profiler-toolbar.js
108
110
  - app/assets/builds/profiler.css
109
111
  - app/assets/builds/profiler.js