rails-profiler 0.30.8 → 0.30.9

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: 7edf1c383b05c79e4013d76c96fa57077b376f3a312c27aa355927ca1bb7e362
4
- data.tar.gz: b1b9b6bd25f7f739c14d2afe72309fac7455e8f2c62b5e7d81e206a09b8ba4fa
3
+ metadata.gz: a3a3ebc22965fd603d63bafecef777474fc488912625ac2950e618566bce8f09
4
+ data.tar.gz: e82aa1ce06e0f24cfb65b86fe01a942557851735f596edb3172c3fd00d7941f3
5
5
  SHA512:
6
- metadata.gz: ca0ca79568606603601b5b33aa3ee69fb54d6497e8c8e2087f45cb8d78092ad0264ed8a8794472955e6b5608f743d198de31fcdf63493e40b7f538fd4c4d8298
7
- data.tar.gz: fd3f7cb5326209b731f11abbca7e7e0b3b226ce019ea62f32636a137d1d402c26f692dc11754e27d3144bae0814524ea7be282031d8adacfe3bda5a97d3d4cc9
6
+ metadata.gz: 857313cb42832f5992a26897cbe6cef4d7dd66d6f59b5fccc63c8758db55da54cd6e3587e254e3d1410db561b668782ef8f3fcda73e4535235efba1e9a34c9be
7
+ data.tar.gz: 1bd504a236e7c548011ceed8dcc1488b21bc89400d824306c2f90d8b1817290768a10264265ee093f78d295f873b72a250a00a96fc5fe04f54f85cd2d63597db
data/CHANGELOG.md CHANGED
@@ -19,6 +19,34 @@ every commit of every tag interval is accounted for one way or the other.
19
19
 
20
20
  ## [Unreleased]
21
21
 
22
+ ## [0.30.9] - 2026-10-04
23
+
24
+ <!-- stamped -->
25
+
26
+ ### Security
27
+
28
+ - **Database:** Explain only read-only queries. The **Explain** button, `POST /_profiler/api/explain`
29
+ and the MCP `explain_query` tool ran `EXPLAIN ANALYZE` on any stored query, and PostgreSQL's
30
+ `EXPLAIN ANALYZE` runs the statement it explains: explaining a `DELETE`, `UPDATE` or `INSERT` the
31
+ application had just run ran it a second time, for real. Only statements starting with `SELECT`,
32
+ `WITH`, `TABLE` or `VALUES` are explained now, and a writing CTE, `SELECT ... INTO`, `FOR UPDATE`
33
+ or a second statement is refused too, with a 422 from the endpoint and an error from the MCP tool.
34
+ The probe also runs in a transaction that is always rolled back, read-only on PostgreSQL.
35
+ Upgrading: nothing to set, and no setting brings back the explaining of writes, since the old
36
+ behaviour was to run them again; to see the plan of a write, run it yourself in `psql` between
37
+ `BEGIN` and `ROLLBACK`, as the README shows.
38
+ - **Database:** Run the Explain probe on a connection of its own, opened outside the pool and closed
39
+ afterwards, so that nothing it does to its session outlives it: a `SELECT pg_try_advisory_lock(...)`
40
+ explained from a profile left the lock held by a connection of the pool. On PostgreSQL the probe
41
+ is also limited to 30 seconds (`statement_timeout`), and its EXPLAIN goes by the extended
42
+ protocol, which refuses a second statement.
43
+
44
+ ### Fixed
45
+
46
+ - **Database:** Put bind values back correctly when explaining a query with ten binds or more
47
+ (`$10` was read as `$1` followed by `0`), and leave alone a `?` or `$1` inside a string literal or
48
+ inside a value already put back.
49
+
22
50
  ## [0.30.8] - 2026-10-04
23
51
 
24
52
  <!-- stamped -->
@@ -1,9 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "profiler/sql_statement"
4
+
3
5
  module Profiler
4
6
  # Shared service for running EXPLAIN ANALYZE on a stored query.
5
7
  # Used by both the HTTP API controller and the MCP explain_query tool.
8
+ #
9
+ # Only read-only statements are explained: PostgreSQL's EXPLAIN ANALYZE runs the
10
+ # statement, so explaining a write would replay it. The probe itself runs, for
11
+ # what no reading of the statement can rule out (a function with a side effect),
12
+ # on a connection of its own that is closed afterwards, in a transaction that is
13
+ # always rolled back, read-only and time-limited on PostgreSQL.
6
14
  module ExplainRunner
15
+ # An ArgumentError, so callers answer it as a bad request (422, MCP error).
16
+ class UnsafeStatementError < ArgumentError; end
17
+
18
+ # PostgreSQL's EXPLAIN ANALYZE runs the query: a slow one is stopped after this.
19
+ PROBE_STATEMENT_TIMEOUT = "30s"
20
+
7
21
  # @param profile_token [String]
8
22
  # @param query_index [Integer]
9
23
  # @return [Hash] { result:, format: "json"|"text", adapter: String }
@@ -35,11 +49,14 @@ module Profiler
35
49
 
36
50
  conn = ActiveRecord::Base.connection
37
51
  adapter = conn.adapter_name.downcase
52
+ refusal = SqlStatement.read_only_refusal(sql, dialect: dialect_for(adapter))
53
+ raise UnsafeStatementError, refusal if refusal
54
+
38
55
  full_sql = reconstruct_sql(sql, binds, conn, adapter)
39
56
 
40
57
  explain_sql, format = build_explain_statement(full_sql, adapter)
41
58
 
42
- rows = conn.exec_query(explain_sql, "EXPLAIN").to_a
59
+ rows = probe(explain_sql, adapter)
43
60
 
44
61
  result = if format == "json"
45
62
  # PostgreSQL / MySQL return JSON in rows[0]["QUERY PLAN"] or rows[0]["EXPLAIN"]
@@ -55,21 +72,51 @@ module Profiler
55
72
  private
56
73
 
57
74
  def self.reconstruct_sql(sql, binds, conn, adapter)
58
- return sql if binds.empty?
75
+ SqlStatement.substitute(sql, binds, dialect: dialect_for(adapter)) { |value| conn.quote(value) }
76
+ end
59
77
 
60
- if adapter.include?("postgresql")
61
- result = sql.dup
62
- binds.each_with_index do |value, i|
63
- result = result.gsub("$#{i + 1}", conn.quote(value))
78
+ def self.dialect_for(adapter)
79
+ if adapter.include?("postgresql") then :postgresql
80
+ elsif adapter.include?("mysql") || adapter.include?("trilogy") then :mysql
81
+ else :sqlite
82
+ end
83
+ end
84
+
85
+ # Runs on a connection of its own, opened outside the pool and closed
86
+ # afterwards, so that nothing the probe does to its session outlives it (an
87
+ # advisory lock, a setting, a LISTEN), the application's own transaction is
88
+ # never touched, and a full pool does not keep Explain waiting.
89
+ # The transaction is always rolled back. On PostgreSQL it is also read-only and
90
+ # time-limited, and the EXPLAIN goes by the extended protocol, which refuses
91
+ # more than one statement: a COMMIT slipped in could not end the read-only
92
+ # transaction. Neither MySQL's EXPLAIN nor SQLite's EXPLAIN QUERY PLAN runs the
93
+ # statement.
94
+ def self.probe(explain_sql, adapter)
95
+ conn = open_probe_connection
96
+ begin
97
+ rows = nil
98
+ conn.transaction do
99
+ if adapter.include?("postgresql")
100
+ conn.execute("SET TRANSACTION READ ONLY")
101
+ conn.execute("SET LOCAL statement_timeout = #{conn.quote(PROBE_STATEMENT_TIMEOUT)}")
102
+ rows = conn.raw_connection.exec_params(explain_sql, []).to_a
103
+ else
104
+ rows = conn.exec_query(explain_sql, "EXPLAIN").to_a
105
+ end
106
+ raise ActiveRecord::Rollback
64
107
  end
65
- result
108
+ rows
109
+ ensure
110
+ conn.disconnect!
111
+ end
112
+ end
113
+
114
+ def self.open_probe_connection
115
+ db_config = ActiveRecord::Base.connection_pool.db_config
116
+ if db_config.respond_to?(:new_connection) # Active Record 7.2 and later
117
+ db_config.new_connection
66
118
  else
67
- # MySQL / SQLite: replace ? sequentially
68
- result = sql.dup
69
- binds.each do |value|
70
- result = result.sub("?", conn.quote(value))
71
- end
72
- result
119
+ ActiveRecord::Base.public_send(db_config.adapter_method, db_config.configuration_hash)
73
120
  end
74
121
  end
75
122
 
@@ -139,7 +139,7 @@ module Profiler
139
139
  ),
140
140
  define_tool(
141
141
  name: "explain_query",
142
- description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only available in development/test environments.",
142
+ description: "Run EXPLAIN ANALYZE on a specific query from a profile. Returns the query execution plan with cost and row estimates. Only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) are explained, in a transaction always rolled back; other queries return an error. Only available in development/test environments.",
143
143
  input_schema: {
144
144
  properties: {
145
145
  token: { type: "string", description: "Profile token, or 'latest' for the most recent profile (required)" },
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module Profiler
6
+ # Reads a stored SQL statement the way the database would, one token at a time:
7
+ # comments, string literals and quoted identifiers are never mistaken for keywords
8
+ # or placeholders. Used by ExplainRunner to refuse anything but a read, and to put
9
+ # the bind values back into the statement.
10
+ module SqlStatement
11
+ # The first keyword of a statement EXPLAIN may run.
12
+ READ_ONLY_VERBS = %w[SELECT WITH TABLE VALUES].freeze
13
+
14
+ # Keywords that make a read-only verb write or lock: a CTE that writes,
15
+ # SELECT ... INTO (and MySQL's INTO OUTFILE), FOR UPDATE / FOR SHARE,
16
+ # LOCK IN SHARE MODE. Matched as whole words outside literals, so a column
17
+ # named `updated_at` is fine and an unquoted column named `share` is refused.
18
+ WRITE_KEYWORDS = %w[INSERT UPDATE DELETE MERGE TRUNCATE DROP ALTER CREATE INTO LOCK SHARE].freeze
19
+
20
+ Token = Struct.new(:type, :text)
21
+
22
+ # 'it''s' everywhere; 'it\'s' too where a backslash escapes. An unterminated
23
+ # literal runs to the end: the database refuses such a statement anyway.
24
+ PLAIN_LITERAL = /[Nn]?'(?:[^']|'')*'?/m
25
+ BACKSLASH_LITERAL = /[Nn]?'(?:[^'\\]|''|\\.)*'?/m
26
+ ESCAPE_LITERAL = /[Ee]'(?:[^'\\]|''|\\.)*'?/m
27
+
28
+ # Dialects differ in what a literal or a comment is: a backslash escapes a quote
29
+ # in every MySQL string but only in PostgreSQL's E'...' strings, dollar quoting
30
+ # is PostgreSQL's, backticks quote identifiers in MySQL and SQLite, MySQL starts
31
+ # a comment with `#` and needs a space after `--`, and only PostgreSQL nests
32
+ # block comments.
33
+ DIALECTS = %i[postgresql mysql sqlite].freeze
34
+
35
+ # @param dialect [Symbol] one of DIALECTS
36
+ # @return [String, nil] why the statement must not be explained, or nil when it reads only
37
+ def self.read_only_refusal(sql, dialect:)
38
+ all = tokenize(sql, dialect)
39
+ if dialect == :mysql && all.any? { |t| t.type == :comment && t.text.start_with?("/*!") }
40
+ return refusal("it holds a MySQL executable comment (/*! ... */)")
41
+ end
42
+
43
+ tokens = all.reject { |t| %i[space comment].include?(t.type) }
44
+ first = tokens.find { |t| t.type != :punct || t.text != "(" }
45
+ return refusal("it is empty") if first.nil?
46
+
47
+ verb = first.text.upcase
48
+ return refusal("it starts with #{verb}") unless first.type == :word && READ_ONLY_VERBS.include?(verb)
49
+
50
+ tokens.each_with_index do |token, i|
51
+ if token.type == :word && WRITE_KEYWORDS.include?(token.text.upcase)
52
+ return refusal("it contains #{token.text.upcase}")
53
+ end
54
+ if token.type == :punct && token.text == ";" && i != tokens.size - 1
55
+ return refusal("it holds more than one statement")
56
+ end
57
+ end
58
+ nil
59
+ end
60
+
61
+ # Replaces each placeholder by its quoted value in a single pass: `$1`, `$10`...
62
+ # by number (PostgreSQL), or `?` in order (MySQL, SQLite). Placeholders inside
63
+ # literals or comments stay as they are, and a substituted value is never read
64
+ # again. A placeholder without a value is left in place.
65
+ def self.substitute(sql, binds, dialect:, &quote)
66
+ return sql if binds.empty?
67
+
68
+ position = 0
69
+ tokenize(sql, dialect).map do |token|
70
+ index = case token.type
71
+ when :numbered then token.text[1..].to_i - 1
72
+ when :question then (position += 1) - 1
73
+ end
74
+ index && index >= 0 && index < binds.size ? quote.call(binds[index]) : token.text
75
+ end.join
76
+ end
77
+
78
+ def self.refusal(reason)
79
+ "EXPLAIN refused: only read-only statements (SELECT, WITH ... SELECT, TABLE, VALUES) " \
80
+ "are explained, and #{reason}. PostgreSQL's EXPLAIN ANALYZE runs the statement it explains."
81
+ end
82
+
83
+ # Splits the statement into tokens whose texts, joined, give it back unchanged.
84
+ def self.tokenize(sql, dialect)
85
+ raise ArgumentError, "unknown SQL dialect: #{dialect.inspect}" unless DIALECTS.include?(dialect)
86
+
87
+ s = StringScanner.new(sql.to_s)
88
+ tokens = []
89
+ until s.eos?
90
+ tokens << if s.scan(/\s+/)
91
+ Token.new(:space, s.matched)
92
+ elsif s.scan(dialect == :mysql ? /(?:--(?=\s|\z)|#)[^\n]*/ : /--[^\n]*/)
93
+ Token.new(:comment, s.matched)
94
+ elsif s.check(%r{/\*})
95
+ Token.new(:comment, scan_block_comment(s, nested: dialect == :postgresql))
96
+ elsif s.scan(dialect == :mysql ? BACKSLASH_LITERAL : PLAIN_LITERAL) ||
97
+ (dialect == :postgresql && s.scan(ESCAPE_LITERAL))
98
+ Token.new(:literal, s.matched)
99
+ elsif s.scan(/"(?:[^"]|"")*"?/m) || (dialect != :postgresql && s.scan(/`(?:[^`]|``)*`?/m))
100
+ Token.new(:identifier, s.matched)
101
+ elsif dialect == :postgresql && s.scan(/\$\d+/)
102
+ Token.new(:numbered, s.matched)
103
+ elsif dialect == :postgresql && s.scan(/\$([A-Za-z_][A-Za-z0-9_]*)?\$/)
104
+ tag = s.matched
105
+ body = s.scan_until(/#{Regexp.escape(tag)}/) || s.rest.tap { s.terminate }
106
+ Token.new(:literal, tag + body)
107
+ elsif dialect != :postgresql && s.scan(/\?/)
108
+ Token.new(:question, s.matched)
109
+ elsif s.scan(/[A-Za-z_][A-Za-z0-9_$]*/)
110
+ Token.new(:word, s.matched)
111
+ else
112
+ Token.new(:punct, s.getch)
113
+ end
114
+ end
115
+ tokens
116
+ end
117
+
118
+ # An unterminated block comment runs to the end.
119
+ def self.scan_block_comment(s, nested:)
120
+ text = +""
121
+ depth = 0
122
+ until s.eos?
123
+ if s.scan(%r{/\*})
124
+ depth += 1 if nested || depth.zero?
125
+ elsif s.scan(%r{\*/})
126
+ depth -= 1
127
+ else
128
+ s.getch
129
+ end
130
+ text << s.matched
131
+ break if depth.zero?
132
+ end
133
+ text
134
+ end
135
+
136
+ private_class_method :refusal, :tokenize, :scan_block_comment
137
+ end
138
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.8"
4
+ VERSION = "0.30.9"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.8
4
+ version: 0.30.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -210,6 +210,7 @@ files:
210
210
  - lib/profiler/models/timeline_event.rb
211
211
  - lib/profiler/railtie.rb
212
212
  - lib/profiler/redaction.rb
213
+ - lib/profiler/sql_statement.rb
213
214
  - lib/profiler/sse/bus.rb
214
215
  - lib/profiler/sse/event_bus.rb
215
216
  - lib/profiler/sse/redis_event_bus.rb