dbviewer 0.6.2 → 0.6.3
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/README.md +1 -1
- data/app/controllers/concerns/dbviewer/database_operations.rb +89 -100
- data/app/controllers/dbviewer/api/entity_relationship_diagrams_controller.rb +25 -23
- data/app/controllers/dbviewer/tables_controller.rb +9 -9
- data/app/helpers/dbviewer/application_helper.rb +33 -13
- data/app/views/dbviewer/tables/show.html.erb +225 -139
- data/app/views/layouts/dbviewer/application.html.erb +55 -0
- data/lib/dbviewer/database/dynamic_model_factory.rb +40 -5
- data/lib/dbviewer/datatable/query_operations.rb +52 -197
- data/lib/dbviewer/engine.rb +1 -22
- data/lib/dbviewer/query/executor.rb +1 -1
- data/lib/dbviewer/query/notification_subscriber.rb +46 -0
- data/lib/dbviewer/validator/sql.rb +198 -0
- data/lib/dbviewer/validator.rb +9 -0
- data/lib/dbviewer/version.rb +1 -1
- data/lib/dbviewer.rb +69 -45
- data/lib/generators/dbviewer/templates/initializer.rb +15 -0
- metadata +5 -3
- data/lib/dbviewer/sql_validator.rb +0 -194
@@ -90,104 +90,7 @@ module Dbviewer
|
|
90
90
|
0
|
91
91
|
end
|
92
92
|
|
93
|
-
|
94
|
-
# @param table_name [String] Name of the table
|
95
|
-
# @param column_name [String] Name of the column
|
96
|
-
# @param limit [Integer] Maximum number of distinct values to return
|
97
|
-
# @return [Array<Hash>] Array of value distribution data with labels and counts
|
98
|
-
def fetch_column_distribution(table_name, column_name, limit = 20)
|
99
|
-
return [] unless column_exists?(table_name, column_name)
|
100
|
-
|
101
|
-
query = "SELECT #{column_name} as label, COUNT(*) as count FROM #{table_name}
|
102
|
-
WHERE #{column_name} IS NOT NULL
|
103
|
-
GROUP BY #{column_name}
|
104
|
-
ORDER BY count DESC LIMIT #{limit}"
|
105
|
-
|
106
|
-
begin
|
107
|
-
result = @connection.execute(query)
|
108
|
-
adapter = @connection.adapter_name.downcase
|
109
|
-
|
110
|
-
# Format depends on adapter
|
111
|
-
if adapter =~ /mysql/
|
112
|
-
result.to_a.map { |row| { label: row[0], value: row[1] } }
|
113
|
-
elsif adapter =~ /sqlite/
|
114
|
-
result.map { |row| { label: row["label"], value: row["count"] } }
|
115
|
-
else # postgresql
|
116
|
-
result.map { |row| { label: row["label"], value: row["count"] } }
|
117
|
-
end
|
118
|
-
rescue => e
|
119
|
-
Rails.logger.error("Error fetching column distribution: #{e.message}")
|
120
|
-
[]
|
121
|
-
end
|
122
|
-
end
|
123
|
-
|
124
|
-
# Get timestamp aggregation data for charts
|
125
|
-
# @param table_name [String] Name of the table
|
126
|
-
# @param grouping [String] Grouping type (hourly, daily, weekly, monthly)
|
127
|
-
# @param column [String] Timestamp column name (defaults to created_at)
|
128
|
-
# @return [Array<Hash>] Array of timestamp data with labels and counts
|
129
|
-
def fetch_timestamp_data(table_name, grouping = "daily", column = "created_at")
|
130
|
-
return [] unless column_exists?(table_name, column)
|
131
|
-
|
132
|
-
adapter = @connection.adapter_name.downcase
|
133
|
-
|
134
|
-
date_format = case grouping
|
135
|
-
when "hourly"
|
136
|
-
if adapter =~ /mysql/
|
137
|
-
"DATE_FORMAT(#{column}, '%Y-%m-%d %H:00')"
|
138
|
-
elsif adapter =~ /sqlite/
|
139
|
-
"strftime('%Y-%m-%d %H:00', #{column})"
|
140
|
-
else # postgresql
|
141
|
-
"TO_CHAR(#{column}, 'YYYY-MM-DD HH24:00')"
|
142
|
-
end
|
143
|
-
when "weekly"
|
144
|
-
if adapter =~ /mysql/
|
145
|
-
"DATE_FORMAT(#{column}, '%Y-%v')"
|
146
|
-
elsif adapter =~ /sqlite/
|
147
|
-
"strftime('%Y-%W', #{column})"
|
148
|
-
else # postgresql
|
149
|
-
"TO_CHAR(#{column}, 'YYYY-IW')"
|
150
|
-
end
|
151
|
-
when "monthly"
|
152
|
-
if adapter =~ /mysql/
|
153
|
-
"DATE_FORMAT(#{column}, '%Y-%m')"
|
154
|
-
elsif adapter =~ /sqlite/
|
155
|
-
"strftime('%Y-%m', #{column})"
|
156
|
-
else # postgresql
|
157
|
-
"TO_CHAR(#{column}, 'YYYY-MM')"
|
158
|
-
end
|
159
|
-
else # daily is default
|
160
|
-
if adapter =~ /mysql/
|
161
|
-
"DATE(#{column})"
|
162
|
-
elsif adapter =~ /sqlite/
|
163
|
-
"date(#{column})"
|
164
|
-
else # postgresql
|
165
|
-
"DATE(#{column})"
|
166
|
-
end
|
167
|
-
end
|
168
|
-
|
169
|
-
# Query works the same for all database adapters
|
170
|
-
query = "SELECT #{date_format} as label, COUNT(*) as count FROM #{table_name}
|
171
|
-
WHERE #{column} IS NOT NULL
|
172
|
-
GROUP BY label
|
173
|
-
ORDER BY MIN(#{column}) DESC LIMIT 30"
|
174
|
-
|
175
|
-
begin
|
176
|
-
result = @connection.execute(query)
|
177
|
-
|
178
|
-
# Format depends on adapter
|
179
|
-
if adapter =~ /mysql/
|
180
|
-
result.to_a.map { |row| { label: row[0], value: row[1] } }
|
181
|
-
elsif adapter =~ /sqlite/
|
182
|
-
result.map { |row| { label: row["label"], value: row["count"] } }
|
183
|
-
else # postgresql
|
184
|
-
result.map { |row| { label: row["label"], value: row["count"] } }
|
185
|
-
end
|
186
|
-
rescue => e
|
187
|
-
Rails.logger.error("Error fetching timestamp data: #{e.message}")
|
188
|
-
[]
|
189
|
-
end
|
190
|
-
end
|
93
|
+
## -- Delegator
|
191
94
|
|
192
95
|
# Execute a raw SQL query after validating for safety
|
193
96
|
# @param sql [String] SQL query to execute
|
@@ -223,112 +126,64 @@ module Dbviewer
|
|
223
126
|
def apply_column_filters(query, table_name, column_filters)
|
224
127
|
return query unless column_filters.present?
|
225
128
|
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
# Different databases may require different SQL for datetime comparison
|
250
|
-
adapter_name = connection.adapter_name.downcase
|
251
|
-
|
252
|
-
if adapter_name =~ /mysql/
|
253
|
-
query = query.where("#{column_name} BETWEEN ? AND ?", start_datetime, end_datetime)
|
254
|
-
elsif adapter_name =~ /sqlite/
|
255
|
-
# SQLite needs special handling for datetime format
|
256
|
-
query = query.where("datetime(#{column_name}) BETWEEN datetime(?) AND datetime(?)", start_datetime, end_datetime)
|
257
|
-
else # postgresql
|
258
|
-
query = query.where("#{column_name} >= ? AND #{column_name} <= ?", start_datetime, end_datetime)
|
259
|
-
end
|
260
|
-
|
261
|
-
# Remove these from filters as they're handled above
|
262
|
-
filters.delete("created_at")
|
263
|
-
filters.delete("created_at_end")
|
264
|
-
|
265
|
-
rescue => e
|
266
|
-
Rails.logger.error("[DBViewer] Error parsing date range: #{e.message}")
|
267
|
-
# Remove the problematic filters and continue
|
268
|
-
filters.delete("created_at")
|
269
|
-
filters.delete("created_at_end")
|
270
|
-
end
|
129
|
+
column_filters.each do |column, value|
|
130
|
+
query = apply_single_column_filter(query, table_name, column, value, column_filters)
|
131
|
+
end
|
132
|
+
|
133
|
+
query
|
134
|
+
end
|
135
|
+
|
136
|
+
# Apply a single column filter to a query
|
137
|
+
# @param query [ActiveRecord::Relation] The query to apply the filter to
|
138
|
+
# @param table_name [String] Name of the table
|
139
|
+
# @param column [String] The column name
|
140
|
+
# @param value [String] The filter value
|
141
|
+
# @param column_filters [Hash] All column filters for accessing operator
|
142
|
+
# @return [ActiveRecord::Relation] The filtered query
|
143
|
+
def apply_single_column_filter(query, table_name, column, value, column_filters)
|
144
|
+
return query unless column_exists?(table_name, column)
|
145
|
+
return query if column.end_with?("_operator")
|
146
|
+
|
147
|
+
operator = column_filters["#{column}_operator"]
|
148
|
+
|
149
|
+
if special_operators(query)[operator]
|
150
|
+
return special_operators(query)[operator].call(column)
|
271
151
|
end
|
152
|
+
return query if value.blank?
|
272
153
|
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
#
|
278
|
-
if column.end_with?("_operator")
|
279
|
-
next # Skip operator fields - they're processed with their column
|
280
|
-
end
|
281
|
-
|
282
|
-
column_sym = column.to_sym
|
283
|
-
operator = filters["#{column}_operator"]
|
284
|
-
|
285
|
-
# Special handling for is_null and is_not_null operators that don't need a value
|
286
|
-
if operator == "is_null" || value == "is_null"
|
287
|
-
Rails.logger.debug("[DBViewer] Applying null filter: #{column} IS NULL")
|
288
|
-
query = query.where("#{column} IS NULL")
|
289
|
-
next
|
290
|
-
elsif operator == "is_not_null" || value == "is_not_null"
|
291
|
-
Rails.logger.debug("[DBViewer] Applying not null filter: #{column} IS NOT NULL")
|
292
|
-
query = query.where("#{column} IS NOT NULL")
|
293
|
-
next
|
294
|
-
end
|
295
|
-
|
296
|
-
# Skip if no value and we're not using a special operator
|
297
|
-
next if value.blank? || value == "is_null" || value == "is_not_null"
|
298
|
-
|
299
|
-
Rails.logger.debug("[DBViewer] Applying filter: #{column} = #{value}")
|
300
|
-
|
301
|
-
# Handle different types of filtering
|
302
|
-
if value.to_s.include?("%") || value.to_s.include?("*")
|
303
|
-
# Pattern matching (LIKE operation)
|
304
|
-
pattern = value.to_s.gsub("*", "%")
|
305
|
-
query = query.where("#{column} LIKE ?", pattern)
|
306
|
-
elsif value.to_s.start_with?(">=", "<=", ">", "<", "!=")
|
307
|
-
# Comparison operators
|
308
|
-
operator = value.to_s.match(/^(>=|<=|>|<|!=)/)[1]
|
309
|
-
comparison_value = value.to_s.gsub(/^(>=|<=|>|<|!=)\s*/, "")
|
310
|
-
|
311
|
-
case operator
|
312
|
-
when ">="
|
313
|
-
query = query.where("#{column} >= ?", comparison_value)
|
314
|
-
when "<="
|
315
|
-
query = query.where("#{column} <= ?", comparison_value)
|
316
|
-
when ">"
|
317
|
-
query = query.where("#{column} > ?", comparison_value)
|
318
|
-
when "<"
|
319
|
-
query = query.where("#{column} < ?", comparison_value)
|
320
|
-
when "!="
|
321
|
-
query = query.where("#{column} != ?", comparison_value)
|
322
|
-
end
|
323
|
-
else
|
324
|
-
# Exact match
|
325
|
-
query = query.where(column_sym => value)
|
326
|
-
end
|
154
|
+
if operator && operator_handlers(query)[operator]
|
155
|
+
return operator_handlers(query)[operator].call(column, value)
|
156
|
+
else
|
157
|
+
# Default to eq no specific operator is provided
|
158
|
+
return query.where("#{column} = ?", value)
|
327
159
|
end
|
328
160
|
|
329
161
|
query
|
330
162
|
end
|
331
163
|
|
164
|
+
def special_operators(query)
|
165
|
+
{
|
166
|
+
"is_null" => ->(column) { query.where("#{column} IS NULL") },
|
167
|
+
"is_not_null" => ->(column) { query.where("#{column} IS NOT NULL") }
|
168
|
+
}
|
169
|
+
end
|
170
|
+
|
171
|
+
def operator_handlers(query)
|
172
|
+
{
|
173
|
+
"contains" => ->(column, value) { query.where("#{column} LIKE ?", "%#{value}%") },
|
174
|
+
"not_contain" => ->(column, value) { query.where("#{column} NOT LIKE ?", "%#{value}%") },
|
175
|
+
"starts_with" => ->(column, value) { query.where("#{column} LIKE ?", "#{value}%") },
|
176
|
+
"ends_with" => ->(column, value) { query.where("#{column} LIKE ?", "%#{value}") },
|
177
|
+
"equals" => ->(column, value) { query.where(column.to_sym => value) },
|
178
|
+
"eq" => ->(column, value) { query.where("#{column} = ?", value) },
|
179
|
+
"gte" => ->(column, value) { query.where("#{column} >= ?", value) },
|
180
|
+
"lte" => ->(column, value) { query.where("#{column} <= ?", value) },
|
181
|
+
"gt" => ->(column, value) { query.where("#{column} > ?", value) },
|
182
|
+
"lt" => ->(column, value) { query.where("#{column} < ?", value) },
|
183
|
+
"neq" => ->(column, value) { query.where("#{column} != ?", value) }
|
184
|
+
}
|
185
|
+
end
|
186
|
+
|
332
187
|
# Helper methods delegated to managers
|
333
188
|
def get_model_for(table_name)
|
334
189
|
@dynamic_model_factory.get_model_for(table_name)
|
data/lib/dbviewer/engine.rb
CHANGED
@@ -14,32 +14,11 @@ module Dbviewer
|
|
14
14
|
|
15
15
|
# Initialize the engine safely
|
16
16
|
initializer "dbviewer.setup", after: :load_config_initializers do |app|
|
17
|
-
Dbviewer.init
|
18
17
|
Dbviewer.setup
|
19
18
|
end
|
20
19
|
|
21
20
|
initializer "dbviewer.notifications" do
|
22
|
-
|
23
|
-
|
24
|
-
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
25
|
-
event = ActiveSupport::Notifications::Event.new(*args)
|
26
|
-
|
27
|
-
next if skip_internal_query?(event)
|
28
|
-
|
29
|
-
Dbviewer::Query::Logger.instance.add(event)
|
30
|
-
end
|
31
|
-
end
|
32
|
-
|
33
|
-
private
|
34
|
-
|
35
|
-
def skip_internal_query?(event)
|
36
|
-
caller_locations = caller_locations(1)
|
37
|
-
return false unless caller_locations
|
38
|
-
|
39
|
-
excluded_caller_locations = caller_locations.filter do |caller_location|
|
40
|
-
!caller_location.path.include?("lib/dbviewer/engine.rb")
|
41
|
-
end
|
42
|
-
excluded_caller_locations.any? { |l| l.path.include?("dbviewer") }
|
21
|
+
Dbviewer::Query::NotificationSubscriber.subscribe
|
43
22
|
end
|
44
23
|
end
|
45
24
|
end
|
@@ -16,7 +16,7 @@ module Dbviewer
|
|
16
16
|
# @raise [StandardError] If the query is invalid or unsafe
|
17
17
|
def execute_query(sql)
|
18
18
|
# Validate and normalize the SQL
|
19
|
-
normalized_sql = ::Dbviewer::
|
19
|
+
normalized_sql = ::Dbviewer::Validator::Sql.validate!(sql.to_s)
|
20
20
|
|
21
21
|
# Get max records from configuration
|
22
22
|
max_records = @config.max_records || 10000
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbviewer
|
4
|
+
module Query
|
5
|
+
# Handles ActiveSupport::Notifications subscription for SQL query monitoring
|
6
|
+
# Only active in development environment to capture and log SQL queries
|
7
|
+
class NotificationSubscriber
|
8
|
+
class << self
|
9
|
+
# Subscribe to SQL notifications if in development environment
|
10
|
+
def subscribe
|
11
|
+
return unless Rails.env.development?
|
12
|
+
|
13
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
|
14
|
+
process_notification(*args)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
# Process a single SQL notification event
|
21
|
+
# @param args [Array] Notification arguments from ActiveSupport::Notifications
|
22
|
+
def process_notification(*args)
|
23
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
24
|
+
|
25
|
+
return if skip_internal_query?(event)
|
26
|
+
|
27
|
+
Dbviewer::Query::Logger.instance.add(event)
|
28
|
+
end
|
29
|
+
|
30
|
+
# Determine if this query should be skipped (internal DBViewer queries)
|
31
|
+
# @param event [ActiveSupport::Notifications::Event] The notification event
|
32
|
+
# @return [Boolean] True if the query should be skipped
|
33
|
+
def skip_internal_query?(event)
|
34
|
+
caller_locations = caller_locations(1)
|
35
|
+
return false unless caller_locations
|
36
|
+
|
37
|
+
excluded_caller_locations = caller_locations.filter do |caller_location|
|
38
|
+
!caller_location.path.include?("lib/dbviewer/engine.rb")
|
39
|
+
end
|
40
|
+
|
41
|
+
excluded_caller_locations.any? { |location| location.path.include?("dbviewer") }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,198 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbviewer
|
4
|
+
module Validator
|
5
|
+
# Sql class handles SQL query validation and normalization
|
6
|
+
# to ensure queries are safe (read-only) and properly formatted.
|
7
|
+
# This helps prevent potentially destructive SQL operations.
|
8
|
+
class Sql
|
9
|
+
# List of SQL keywords that could modify data or schema
|
10
|
+
FORBIDDEN_KEYWORDS = %w[
|
11
|
+
UPDATE INSERT DELETE DROP ALTER CREATE TRUNCATE REPLACE
|
12
|
+
RENAME GRANT REVOKE LOCK UNLOCK COMMIT ROLLBACK
|
13
|
+
SAVEPOINT INTO CALL EXECUTE EXEC
|
14
|
+
]
|
15
|
+
|
16
|
+
# List of SQL keywords that should only be allowed in specific contexts
|
17
|
+
CONDITIONAL_KEYWORDS = {
|
18
|
+
# JOIN is allowed, but we should check for suspicious patterns
|
19
|
+
"JOIN" => /\bJOIN\b/i,
|
20
|
+
# UNION is allowed, but potential for injection
|
21
|
+
"UNION" => /\bUNION\b/i,
|
22
|
+
# WITH is allowed for CTEs, but need to ensure it's not a data modification
|
23
|
+
"WITH" => /\bWITH\b/i
|
24
|
+
}
|
25
|
+
|
26
|
+
# Maximum allowed query length
|
27
|
+
MAX_QUERY_LENGTH = 10000
|
28
|
+
|
29
|
+
# Determines if a query is safe (read-only)
|
30
|
+
# @param sql [String] The SQL query to validate
|
31
|
+
# @return [Boolean] true if the query is safe, false otherwise
|
32
|
+
def self.safe_query?(sql)
|
33
|
+
return false if sql.blank?
|
34
|
+
|
35
|
+
# Get max query length from configuration
|
36
|
+
max_length = Dbviewer.configuration.max_query_length || MAX_QUERY_LENGTH
|
37
|
+
return false if sql.length > max_length
|
38
|
+
|
39
|
+
normalized_sql = normalize(sql)
|
40
|
+
|
41
|
+
# Case-insensitive check for SELECT at the beginning
|
42
|
+
return false unless normalized_sql =~ /\A\s*SELECT\s+/i
|
43
|
+
|
44
|
+
# Check for forbidden keywords that might be used in subqueries or other SQL constructs
|
45
|
+
FORBIDDEN_KEYWORDS.each do |keyword|
|
46
|
+
# Look for the keyword with word boundaries to avoid false positives
|
47
|
+
return false if normalized_sql =~ /\b#{keyword}\b/i
|
48
|
+
end
|
49
|
+
|
50
|
+
# Check for suspicious patterns that might indicate SQL injection attempts
|
51
|
+
return false if has_suspicious_patterns?(normalized_sql)
|
52
|
+
|
53
|
+
# Check for multiple statements (;) which could allow executing multiple commands
|
54
|
+
statements = normalized_sql.split(";").reject(&:blank?)
|
55
|
+
return false if statements.size > 1
|
56
|
+
|
57
|
+
# Additional specific checks for common SQL injection patterns
|
58
|
+
return false if has_injection_patterns?(normalized_sql)
|
59
|
+
|
60
|
+
true
|
61
|
+
end
|
62
|
+
|
63
|
+
# Check for suspicious patterns in SQL that might indicate an attack
|
64
|
+
# @param sql [String] Normalized SQL query
|
65
|
+
# @return [Boolean] true if suspicious patterns found, false otherwise
|
66
|
+
def self.has_suspicious_patterns?(sql)
|
67
|
+
# Check for SQL comment sequences that might be used to hide malicious code
|
68
|
+
return true if sql =~ /\s+--/ || sql =~ /\/\*/
|
69
|
+
|
70
|
+
# Check for string concatenation which might be used for injection
|
71
|
+
return true if sql =~ /\|\|/ || sql =~ /CONCAT\s*\(/i
|
72
|
+
|
73
|
+
# Check for excessive number of quotes which might indicate injection
|
74
|
+
single_quotes = sql.count("'")
|
75
|
+
double_quotes = sql.count('"')
|
76
|
+
return true if single_quotes > 20 || double_quotes > 20
|
77
|
+
|
78
|
+
# Check for hex/binary data which might hide malicious code
|
79
|
+
return true if sql =~ /0x[0-9a-f]{16,}/i
|
80
|
+
|
81
|
+
false
|
82
|
+
end
|
83
|
+
|
84
|
+
# Check for specific SQL injection patterns
|
85
|
+
# @param sql [String] Normalized SQL query
|
86
|
+
# @return [Boolean] true if injection patterns found, false otherwise
|
87
|
+
def self.has_injection_patterns?(sql)
|
88
|
+
# Check for typical SQL injection test patterns
|
89
|
+
return true if sql =~ /'\s*OR\s*'.*'\s*=\s*'/i
|
90
|
+
return true if sql =~ /'\s*OR\s*1\s*=\s*1/i
|
91
|
+
return true if sql =~ /'\s*;\s*--/i
|
92
|
+
|
93
|
+
# Check for attempts to determine database type
|
94
|
+
return true if sql =~ /@@version/i
|
95
|
+
return true if sql =~ /version\(\)/i
|
96
|
+
|
97
|
+
false
|
98
|
+
end
|
99
|
+
|
100
|
+
# Normalize SQL by removing comments and extra whitespace
|
101
|
+
# @param sql [String] The SQL query to normalize
|
102
|
+
# @return [String] The normalized SQL query
|
103
|
+
def self.normalize(sql)
|
104
|
+
return "" if sql.nil?
|
105
|
+
|
106
|
+
begin
|
107
|
+
# Remove SQL comments (both -- and /* */ styles)
|
108
|
+
normalized = sql.gsub(/--.*$/, "") # Remove -- style comments
|
109
|
+
.gsub(/\/\*.*?\*\//m, "") # Remove /* */ style comments
|
110
|
+
.gsub(/\s+/, " ") # Normalize whitespace
|
111
|
+
.strip # Remove leading/trailing whitespace
|
112
|
+
|
113
|
+
# Replace multiple spaces with a single space
|
114
|
+
normalized.gsub(/\s{2,}/, " ")
|
115
|
+
rescue => e
|
116
|
+
Rails.logger.error("[DBViewer] SQL normalization error: #{e.message}")
|
117
|
+
""
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
# Validates a query and raises an exception if it's unsafe
|
122
|
+
# @param sql [String] The SQL query to validate
|
123
|
+
# @raise [SecurityError] if the query is unsafe
|
124
|
+
# @return [String] The normalized SQL query if it's safe
|
125
|
+
def self.validate!(sql)
|
126
|
+
if sql.blank?
|
127
|
+
raise SecurityError, "Empty query is not allowed"
|
128
|
+
end
|
129
|
+
|
130
|
+
# Get max query length from configuration
|
131
|
+
max_length = Dbviewer.configuration.max_query_length || MAX_QUERY_LENGTH
|
132
|
+
if sql.length > max_length
|
133
|
+
raise SecurityError, "Query exceeds maximum allowed length (#{max_length} chars)"
|
134
|
+
end
|
135
|
+
|
136
|
+
normalized_sql = normalize(sql)
|
137
|
+
|
138
|
+
# Special case for SQLite PRAGMA statements which are safe read-only commands
|
139
|
+
if normalized_sql =~ /\A\s*PRAGMA\s+[a-z0-9_]+\s*\z/i
|
140
|
+
return normalized_sql
|
141
|
+
end
|
142
|
+
|
143
|
+
unless normalized_sql =~ /\A\s*SELECT\s+/i
|
144
|
+
raise SecurityError, "Query must begin with SELECT for security reasons"
|
145
|
+
end
|
146
|
+
|
147
|
+
FORBIDDEN_KEYWORDS.each do |keyword|
|
148
|
+
if normalized_sql =~ /\b#{keyword}\b/i
|
149
|
+
raise SecurityError, "Forbidden keyword '#{keyword}' detected in query"
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
if has_suspicious_patterns?(normalized_sql)
|
154
|
+
raise SecurityError, "Query contains suspicious patterns that may indicate SQL injection"
|
155
|
+
end
|
156
|
+
|
157
|
+
# Check for multiple statements
|
158
|
+
statements = normalized_sql.split(";").reject(&:blank?)
|
159
|
+
if statements.size > 1
|
160
|
+
raise SecurityError, "Multiple SQL statements are not allowed"
|
161
|
+
end
|
162
|
+
|
163
|
+
if has_injection_patterns?(normalized_sql)
|
164
|
+
raise SecurityError, "Query contains patterns commonly associated with SQL injection attempts"
|
165
|
+
end
|
166
|
+
|
167
|
+
normalized_sql
|
168
|
+
end
|
169
|
+
|
170
|
+
# Check if a query is using a specific database feature that might need special handling
|
171
|
+
# @param sql [String] The SQL query
|
172
|
+
# @param feature [Symbol] The feature to check for (:join, :subquery, :order_by, etc.)
|
173
|
+
# @return [Boolean] true if the feature is used in the query
|
174
|
+
def self.uses_feature?(sql, feature)
|
175
|
+
normalized = normalize(sql)
|
176
|
+
case feature
|
177
|
+
when :join
|
178
|
+
normalized =~ /\b(INNER|LEFT|RIGHT|FULL|CROSS)?\s*JOIN\b/i
|
179
|
+
when :subquery
|
180
|
+
# Check if there are parentheses that likely contain a subquery
|
181
|
+
normalized.count("(") > normalized.count(")")
|
182
|
+
when :order_by
|
183
|
+
normalized =~ /\bORDER\s+BY\b/i
|
184
|
+
when :group_by
|
185
|
+
normalized =~ /\bGROUP\s+BY\b/i
|
186
|
+
when :having
|
187
|
+
normalized =~ /\bHAVING\b/i
|
188
|
+
when :union
|
189
|
+
normalized =~ /\bUNION\b/i
|
190
|
+
when :window_function
|
191
|
+
normalized =~ /\bOVER\s*\(/i
|
192
|
+
else
|
193
|
+
false
|
194
|
+
end
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Dbviewer
|
4
|
+
# Validator namespace contains validation classes for different types of content
|
5
|
+
# such as SQL queries, configuration parameters, etc.
|
6
|
+
module Validator
|
7
|
+
# This module serves as a namespace for various validator classes
|
8
|
+
end
|
9
|
+
end
|
data/lib/dbviewer/version.rb
CHANGED