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 +4 -4
- data/app/controllers/query_console/explain_controller.rb +0 -2
- data/app/controllers/query_console/queries_controller.rb +21 -2
- data/app/services/query_console/audit_logger.rb +22 -6
- data/app/services/query_console/explain_runner.rb +47 -0
- data/app/services/query_console/runner.rb +47 -0
- data/app/services/query_console/sql_validator.rb +18 -5
- data/app/views/query_console/queries/new.html.erb +22 -6
- data/lib/query_console/configuration.rb +2 -0
- data/lib/query_console/engine.rb +1 -1
- data/lib/query_console/version.rb +1 -1
- metadata +7 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19231f63422842fbe797464f96c6208d308ec868d8cb19872cceabd32a132c1a
|
|
4
|
+
data.tar.gz: edeedcb088b35135eabb6b4a445b315832ce0a500424f7718d84300dcfb3e08d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be36ac0c732088e1fc1a683b435dc50597791fe5a46fe917c116de1464f34aa378f70701a09095a52bd677ee6b0b94f5fe758be4e302a1af0ae6d3697f5337a1
|
|
7
|
+
data.tar.gz: 9f5610145cb80d7557c5eb86f1655fb07483397e05616667a450a0b575ffd3c082728795b54c0db189a58dfe12bc46aeb46cdfa3d51ce33c20bbc6dd2321b513
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
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 = '<%=
|
|
598
|
+
form.action = '<%= run_path %>'
|
|
599
599
|
form.setAttribute('data-turbo-frame', 'query-results')
|
|
600
|
-
|
|
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 = '<%=
|
|
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('<%=
|
|
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(`<%=
|
|
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[
|
data/lib/query_console/engine.rb
CHANGED
|
@@ -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
|
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.
|
|
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
|