query_console 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9ea5d214eb67f13c153578fce1f001109518b6935409774d9ae343de3ee87258
4
- data.tar.gz: e7f2c6c7a528ababbef441b2d548cc635222701f5903cbdd1f06b9ecbefd3bc8
3
+ metadata.gz: 19231f63422842fbe797464f96c6208d308ec868d8cb19872cceabd32a132c1a
4
+ data.tar.gz: edeedcb088b35135eabb6b4a445b315832ce0a500424f7718d84300dcfb3e08d
5
5
  SHA512:
6
- metadata.gz: 5780f01ddfc86f3f998ea7f67b449ff312c3e4e305a6a5c454593a9aec6d691879837171d0567d2db8def6b70503d77203eecf708e4878947463b9f0443ba5eb
7
- data.tar.gz: 99f44e0e10f2c06cb428936bfeaf8757bef817353d535ef1088761c6df7ea6a546905ce12c076207a5252103ce7df37a487ff56846a7e89d92c9e37e376de813
6
+ metadata.gz: be36ac0c732088e1fc1a683b435dc50597791fe5a46fe917c116de1464f34aa378f70701a09095a52bd677ee6b0b94f5fe758be4e302a1af0ae6d3697f5337a1
7
+ data.tar.gz: 9f5610145cb80d7557c5eb86f1655fb07483397e05616667a450a0b575ffd3c082728795b54c0db189a58dfe12bc46aeb46cdfa3d51ce33c20bbc6dd2321b513
@@ -1,7 +1,5 @@
1
1
  module QueryConsole
2
2
  class ExplainController < ApplicationController
3
- skip_forgery_protection only: [:create] # Allow Turbo Frame POST requests
4
-
5
3
  def create
6
4
  sql = params[:sql]
7
5
 
@@ -1,7 +1,5 @@
1
1
  module QueryConsole
2
2
  class QueriesController < ApplicationController
3
- skip_forgery_protection only: [:run] # Allow Turbo Frame POST requests
4
-
5
3
  def new
6
4
  # Render the main query editor page
7
5
  end
@@ -18,6 +16,27 @@ module QueryConsole
18
16
  return
19
17
  end
20
18
 
19
+ # SECURITY FIX: Server-side DML confirmation check
20
+ # Validate DML confirmation before execution
21
+ config = QueryConsole.configuration
22
+ if config.enable_dml
23
+ # Quick check if SQL contains DML keywords
24
+ normalized_sql = sql.strip.downcase
25
+ if normalized_sql.match?(/\A(insert|update|delete|merge)\b/)
26
+ # This is a DML query - require confirmation
27
+ unless params[:dml_confirmed] == 'true'
28
+ @result = Runner::QueryResult.new(
29
+ error: "DML query execution requires user confirmation. Please confirm the operation to proceed."
30
+ )
31
+ respond_to do |format|
32
+ format.turbo_stream { render turbo_stream: turbo_stream.replace("query-results", partial: "results", locals: { result: @result, is_dml: false }) }
33
+ format.html { render :_results, layout: false }
34
+ end
35
+ return
36
+ end
37
+ end
38
+ end
39
+
21
40
  # Execute the query
22
41
  runner = Runner.new(sql)
23
42
  @result = runner.execute
@@ -39,17 +39,33 @@ module QueryConsole
39
39
  end
40
40
 
41
41
  def self.is_dml_query?(sql)
42
- sql.to_s.strip.downcase.match?(/\A(insert|update|delete|merge)\b/)
42
+ normalized = sql.to_s.strip.downcase
43
+ # Check if it's a top-level DML query
44
+ is_top_level = normalized.match?(/\A(insert|update|delete|merge)\b/)
45
+ # SECURITY FIX: Also check for DML anywhere in the query (subqueries)
46
+ has_dml_anywhere = normalized.match?(/\b(insert|update|delete|merge)\b/)
47
+
48
+ # Return true if DML detected anywhere (for audit purposes)
49
+ is_top_level || has_dml_anywhere
43
50
  end
44
51
 
45
52
  def self.determine_query_type(sql)
46
- case sql.to_s.strip.downcase
53
+ normalized = sql.to_s.strip.downcase
54
+
55
+ # SECURITY FIX: Check for DML anywhere in query, not just at start
56
+ # This ensures subquery DML is properly logged
57
+ if normalized.match?(/\b(insert|update|delete|merge)\b/)
58
+ # Determine which DML operation (prefer top-level, but detect any)
59
+ return "INSERT" if normalized.match?(/\binsert\b/)
60
+ return "UPDATE" if normalized.match?(/\bupdate\b/)
61
+ return "DELETE" if normalized.match?(/\bdelete\b/)
62
+ return "MERGE" if normalized.match?(/\bmerge\b/)
63
+ end
64
+
65
+ # If no DML, check for SELECT/WITH
66
+ case normalized
47
67
  when /\Aselect\b/ then "SELECT"
48
68
  when /\Awith\b/ then "WITH"
49
- when /\Ainsert\b/ then "INSERT"
50
- when /\Aupdate\b/ then "UPDATE"
51
- when /\Adelete\b/ then "DELETE"
52
- when /\Amerge\b/ then "MERGE"
53
69
  else "UNKNOWN"
54
70
  end
55
71
  end
@@ -96,6 +96,49 @@ module QueryConsole
96
96
  end
97
97
 
98
98
  def execute_with_timeout(sql)
99
+ case @config.timeout_strategy
100
+ when :database
101
+ execute_with_database_timeout(sql)
102
+ when :ruby
103
+ execute_with_ruby_timeout(sql)
104
+ else
105
+ # Auto-detect: use database timeout for PostgreSQL, Ruby timeout otherwise
106
+ if postgresql_connection?
107
+ execute_with_database_timeout(sql)
108
+ else
109
+ execute_with_ruby_timeout(sql)
110
+ end
111
+ end
112
+ end
113
+
114
+ # Database-level timeout (PostgreSQL only)
115
+ # Safer: database cancels the query cleanly, no orphan processes
116
+ def execute_with_database_timeout(sql)
117
+ conn = ActiveRecord::Base.connection
118
+
119
+ unless postgresql_connection?
120
+ Rails.logger.warn("[QueryConsole] Database timeout strategy requires PostgreSQL, falling back to Ruby timeout")
121
+ return execute_with_ruby_timeout(sql)
122
+ end
123
+
124
+ conn.transaction do
125
+ # SET LOCAL scopes the timeout to this transaction only
126
+ conn.execute("SET LOCAL statement_timeout = '#{@config.timeout_ms}'")
127
+ conn.exec_query(sql)
128
+ end
129
+ rescue ActiveRecord::StatementInvalid => e
130
+ if e.message.include?("canceling statement due to statement timeout") ||
131
+ e.message.include?("query_canceled")
132
+ raise Timeout::Error, "Query timeout: exceeded #{@config.timeout_ms}ms limit"
133
+ else
134
+ raise
135
+ end
136
+ end
137
+
138
+ # Ruby-level timeout (fallback for non-PostgreSQL databases)
139
+ # Warning: The database query continues running as an orphan process
140
+ # even after Ruby times out. Can cause resource exhaustion.
141
+ def execute_with_ruby_timeout(sql)
99
142
  timeout_seconds = @config.timeout_ms / 1000.0
100
143
 
101
144
  Timeout.timeout(timeout_seconds) do
@@ -103,6 +146,10 @@ module QueryConsole
103
146
  end
104
147
  end
105
148
 
149
+ def postgresql_connection?
150
+ ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql')
151
+ end
152
+
106
153
  def format_explain_output(result)
107
154
  # For SQLite, the result has columns like: id, parent, notused, detail
108
155
  # For Postgres, it's usually a single "QUERY PLAN" column
@@ -94,6 +94,49 @@ module QueryConsole
94
94
  attr_reader :sql, :config
95
95
 
96
96
  def execute_with_timeout(sql)
97
+ case @config.timeout_strategy
98
+ when :database
99
+ execute_with_database_timeout(sql)
100
+ when :ruby
101
+ execute_with_ruby_timeout(sql)
102
+ else
103
+ # Auto-detect: use database timeout for PostgreSQL, Ruby timeout otherwise
104
+ if postgresql_connection?
105
+ execute_with_database_timeout(sql)
106
+ else
107
+ execute_with_ruby_timeout(sql)
108
+ end
109
+ end
110
+ end
111
+
112
+ # Database-level timeout (PostgreSQL only)
113
+ # Safer: database cancels the query cleanly, no orphan processes
114
+ def execute_with_database_timeout(sql)
115
+ conn = ActiveRecord::Base.connection
116
+
117
+ unless postgresql_connection?
118
+ Rails.logger.warn("[QueryConsole] Database timeout strategy requires PostgreSQL, falling back to Ruby timeout")
119
+ return execute_with_ruby_timeout(sql)
120
+ end
121
+
122
+ conn.transaction do
123
+ # SET LOCAL scopes the timeout to this transaction only
124
+ conn.execute("SET LOCAL statement_timeout = '#{@config.timeout_ms}'")
125
+ conn.exec_query(sql)
126
+ end
127
+ rescue ActiveRecord::StatementInvalid => e
128
+ if e.message.include?("canceling statement due to statement timeout") ||
129
+ e.message.include?("query_canceled")
130
+ raise Timeout::Error, "Query timeout: exceeded #{@config.timeout_ms}ms limit"
131
+ else
132
+ raise
133
+ end
134
+ end
135
+
136
+ # Ruby-level timeout (fallback for non-PostgreSQL databases)
137
+ # Warning: The database query continues running as an orphan process
138
+ # even after Ruby times out. Can cause resource exhaustion.
139
+ def execute_with_ruby_timeout(sql)
97
140
  timeout_seconds = @config.timeout_ms / 1000.0
98
141
 
99
142
  Timeout.timeout(timeout_seconds) do
@@ -101,6 +144,10 @@ module QueryConsole
101
144
  end
102
145
  end
103
146
 
147
+ def postgresql_connection?
148
+ ActiveRecord::Base.connection.adapter_name.downcase.include?('postgresql')
149
+ end
150
+
104
151
  # Get the number of rows affected by a DML query
105
152
  # This is database-specific, so we try different approaches
106
153
  def get_affected_rows_count(result)
@@ -67,11 +67,24 @@ module QueryConsole
67
67
  # Check for forbidden keywords
68
68
  normalized_query = sanitized.downcase
69
69
 
70
- # Filter forbidden keywords based on DML enablement
71
- effective_forbidden = if @config.enable_dml
72
- @config.forbidden_keywords.reject { |kw| dml_keywords.include?(kw) || kw == 'replace' || kw == 'into' }
70
+ # SECURITY FIX: When DML is enabled, we still need to prevent DML in subqueries
71
+ # Only allow DML at the top level (start of query)
72
+ if @config.enable_dml
73
+ # Check if this is a top-level DML query
74
+ is_top_level_dml = normalized_start.match?(/\A(insert|update|delete|merge)\b/)
75
+
76
+ # If DML keywords appear anywhere else (subqueries, CTEs), block them
77
+ if !is_top_level_dml && normalized_query.match?(/\b(insert|update|delete|merge)\b/)
78
+ return ValidationResult.new(
79
+ valid: false,
80
+ error: "DML keywords (INSERT, UPDATE, DELETE, MERGE) are not allowed in subqueries or WITH clauses"
81
+ )
82
+ end
83
+
84
+ # Filter forbidden keywords - only remove DML from forbidden list for top-level queries
85
+ effective_forbidden = @config.forbidden_keywords.reject { |kw| dml_keywords.include?(kw) || kw == 'replace' || kw == 'into' }
73
86
  else
74
- @config.forbidden_keywords
87
+ effective_forbidden = @config.forbidden_keywords
75
88
  end
76
89
 
77
90
  forbidden = effective_forbidden.find do |keyword|
@@ -86,7 +99,7 @@ module QueryConsole
86
99
  )
87
100
  end
88
101
 
89
- # Detect if this is a DML query
102
+ # Detect if this is a DML query (top-level only)
90
103
  is_dml_query = sanitized.downcase.match?(/\A(insert|update|delete|merge)\b/)
91
104
 
92
105
  ValidationResult.new(valid: true, sanitized_sql: sanitized, is_dml: is_dml_query)
@@ -595,12 +595,21 @@
595
595
  // Create form with Turbo Frame target
596
596
  const form = document.createElement('form')
597
597
  form.method = 'POST'
598
- form.action = '<%= query_console.run_path %>'
598
+ form.action = '<%= run_path %>'
599
599
  form.setAttribute('data-turbo-frame', 'query-results')
600
- form.innerHTML = `
600
+
601
+ // SECURITY FIX: Add dml_confirmed parameter for server-side verification
602
+ let formHTML = `
601
603
  <input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
602
604
  <input type="hidden" name="authenticity_token" value="${document.querySelector('meta[name=csrf-token]').content}">
603
605
  `
606
+
607
+ // If DML query was confirmed, add confirmation parameter
608
+ if (this.isDmlQuery(sql)) {
609
+ formHTML += `<input type="hidden" name="dml_confirmed" value="true">`
610
+ }
611
+
612
+ form.innerHTML = formHTML
604
613
  document.body.appendChild(form)
605
614
  form.requestSubmit()
606
615
  document.body.removeChild(form)
@@ -622,7 +631,7 @@
622
631
  // Create form with Turbo Frame target
623
632
  const form = document.createElement('form')
624
633
  form.method = 'POST'
625
- form.action = '<%= query_console.explain_path %>'
634
+ form.action = '<%= explain_path %>'
626
635
  form.setAttribute('data-turbo-frame', 'explain-results')
627
636
  form.innerHTML = `
628
637
  <input type="hidden" name="sql" value="${this.escapeHtml(sql)}">
@@ -651,7 +660,7 @@
651
660
 
652
661
  async loadTables() {
653
662
  try {
654
- const response = await fetch('<%= query_console.schema_tables_path %>')
663
+ const response = await fetch('<%= schema_tables_path %>')
655
664
  this.tables = await response.json()
656
665
  this.renderTables()
657
666
  } catch (error) {
@@ -680,7 +689,7 @@
680
689
  const tableName = event.currentTarget.dataset.tableName
681
690
 
682
691
  try {
683
- const response = await fetch(`<%= query_console.schema_tables_path %>/${tableName}`)
692
+ const response = await fetch(`<%= schema_tables_path %>/${tableName}`)
684
693
  const tableData = await response.json()
685
694
  this.renderTableDetails(tableData)
686
695
  } catch (error) {
@@ -790,7 +799,7 @@
790
799
  history.map((item, index) => `
791
800
  <li data-action="click->history#load" data-index="${index}">
792
801
  <div style="font-size: 12px; color: #6c757d;">${new Date(item.timestamp).toLocaleString()}</div>
793
- <div style="font-size: 13px; margin-top: 4px;">${this.truncate(item.sql, 100)}</div>
802
+ <div style="font-size: 13px; margin-top: 4px;">${this.escapeHtml(this.truncate(item.sql, 100))}</div>
794
803
  </li>
795
804
  `).join('') :
796
805
  '<li style="color: #6c757d; text-align: center; padding: 20px;">No query history</li>'
@@ -830,6 +839,13 @@
830
839
  localStorage.setItem(this.storageKeyValue, JSON.stringify(history))
831
840
  }
832
841
 
842
+ // SECURITY FIX: Escape HTML to prevent XSS
843
+ escapeHtml(text) {
844
+ const div = document.createElement('div')
845
+ div.textContent = text
846
+ return div.innerHTML
847
+ }
848
+
833
849
  truncate(str, length) {
834
850
  return str.length > length ? str.substring(0, length) + '...' : str
835
851
  }
@@ -3,6 +3,7 @@ module QueryConsole
3
3
  attr_accessor :enabled_environments,
4
4
  :max_rows,
5
5
  :timeout_ms,
6
+ :timeout_strategy,
6
7
  :authorize,
7
8
  :current_actor,
8
9
  :forbidden_keywords,
@@ -21,6 +22,7 @@ module QueryConsole
21
22
  @enabled_environments = ["development"]
22
23
  @max_rows = 500
23
24
  @timeout_ms = 3000
25
+ @timeout_strategy = :database # :database (safer, PostgreSQL only) or :ruby (fallback, but can leave orphan queries)
24
26
  @authorize = nil # nil means deny by default
25
27
  @current_actor = -> (_controller) { "unknown" }
26
28
  @forbidden_keywords = %w[
@@ -11,7 +11,7 @@ module QueryConsole
11
11
 
12
12
  # Load Hotwire (Turbo & Stimulus)
13
13
  initializer "query_console.importmap", before: "importmap" do |app|
14
- if app.config.respond_to?(:importmap)
14
+ if defined?(Importmap) && app.config.respond_to?(:importmap)
15
15
  app.config.importmap.paths << root.join("config/importmap.rb")
16
16
  end
17
17
  end
@@ -1,3 +1,3 @@
1
1
  module QueryConsole
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: query_console
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johnson Gnanasekar
@@ -16,6 +16,9 @@ dependencies:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
18
  version: 7.0.0
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
@@ -23,6 +26,9 @@ dependencies:
23
26
  - - ">="
24
27
  - !ruby/object:Gem::Version
25
28
  version: 7.0.0
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: turbo-rails
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -51,20 +57,6 @@ dependencies:
51
57
  - - "~>"
52
58
  - !ruby/object:Gem::Version
53
59
  version: '1.3'
54
- - !ruby/object:Gem::Dependency
55
- name: importmap-rails
56
- requirement: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '2.0'
61
- type: :runtime
62
- prerelease: false
63
- version_requirements: !ruby/object:Gem::Requirement
64
- requirements:
65
- - - "~>"
66
- - !ruby/object:Gem::Version
67
- version: '2.0'
68
60
  - !ruby/object:Gem::Dependency
69
61
  name: rspec-rails
70
62
  requirement: !ruby/object:Gem::Requirement