dbviewer 0.6.2 → 0.6.4

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +43 -14
  3. data/app/controllers/concerns/dbviewer/connection_management.rb +88 -0
  4. data/app/controllers/concerns/dbviewer/data_export.rb +32 -0
  5. data/app/controllers/concerns/dbviewer/database_information.rb +62 -0
  6. data/app/controllers/concerns/dbviewer/database_operations.rb +8 -514
  7. data/app/controllers/concerns/dbviewer/datatable_support.rb +47 -0
  8. data/app/controllers/concerns/dbviewer/query_operations.rb +28 -0
  9. data/app/controllers/concerns/dbviewer/relationship_management.rb +173 -0
  10. data/app/controllers/concerns/dbviewer/table_operations.rb +56 -0
  11. data/app/controllers/dbviewer/api/entity_relationship_diagrams_controller.rb +26 -24
  12. data/app/controllers/dbviewer/tables_controller.rb +16 -11
  13. data/app/helpers/dbviewer/application_helper.rb +9 -521
  14. data/app/helpers/dbviewer/database_helper.rb +59 -0
  15. data/app/helpers/dbviewer/filter_helper.rb +137 -0
  16. data/app/helpers/dbviewer/formatting_helper.rb +30 -0
  17. data/app/helpers/dbviewer/navigation_helper.rb +35 -0
  18. data/app/helpers/dbviewer/pagination_helper.rb +72 -0
  19. data/app/helpers/dbviewer/sorting_helper.rb +47 -0
  20. data/app/helpers/dbviewer/table_rendering_helper.rb +145 -0
  21. data/app/helpers/dbviewer/ui_helper.rb +41 -0
  22. data/app/views/dbviewer/tables/show.html.erb +225 -139
  23. data/app/views/layouts/dbviewer/application.html.erb +55 -0
  24. data/lib/dbviewer/database/dynamic_model_factory.rb +40 -5
  25. data/lib/dbviewer/database/manager.rb +2 -3
  26. data/lib/dbviewer/datatable/query_operations.rb +84 -214
  27. data/lib/dbviewer/engine.rb +1 -22
  28. data/lib/dbviewer/query/executor.rb +1 -36
  29. data/lib/dbviewer/query/notification_subscriber.rb +46 -0
  30. data/lib/dbviewer/validator/sql.rb +198 -0
  31. data/lib/dbviewer/validator.rb +9 -0
  32. data/lib/dbviewer/version.rb +1 -1
  33. data/lib/dbviewer.rb +69 -45
  34. data/lib/generators/dbviewer/templates/initializer.rb +15 -0
  35. metadata +20 -3
  36. data/lib/dbviewer/sql_validator.rb +0 -194
@@ -8,13 +8,11 @@ module Dbviewer
8
8
  # Initialize with dependencies
9
9
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] The database connection
10
10
  # @param dynamic_model_factory [Dbviewer::Database::DynamicModelFactory] Factory for creating dynamic AR models
11
- # @param query_executor [Dbviewer::Query::Executor] Executor for raw SQL queries
12
11
  # @param table_metadata_manager [Dbviewer::Database::MetadataManager] Manager for table metadata
13
- def initialize(connection, dynamic_model_factory, query_executor, table_metadata_manager)
12
+ def initialize(connection, dynamic_model_factory, table_metadata_manager)
14
13
  @connection = connection
15
14
  @adapter_name = connection.adapter_name.downcase
16
15
  @dynamic_model_factory = dynamic_model_factory
17
- @query_executor = query_executor
18
16
  @table_metadata_manager = table_metadata_manager
19
17
  @query_analyzer = ::Dbviewer::Query::Analyzer.new(connection)
20
18
  end
@@ -49,7 +47,7 @@ module Dbviewer
49
47
  column_names = table_columns(table_name).map { |c| c[:name] }
50
48
 
51
49
  # Format results
52
- @query_executor.to_result_set(records, column_names)
50
+ to_result_set(records, column_names)
53
51
  rescue => e
54
52
  Rails.logger.error("[DBViewer] Error executing table query: #{e.message}")
55
53
  raise e
@@ -90,120 +88,7 @@ module Dbviewer
90
88
  0
91
89
  end
92
90
 
93
- # Get column histogram/value distribution data for a specific column
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
191
-
192
- # Execute a raw SQL query after validating for safety
193
- # @param sql [String] SQL query to execute
194
- # @return [ActiveRecord::Result] Result set with columns and rows
195
- # @raise [StandardError] If the query is invalid or unsafe
196
- def execute_query(sql)
197
- @query_executor.execute_query(sql)
198
- end
199
-
200
- # Execute a SQLite PRAGMA command without adding a LIMIT clause
201
- # @param pragma [String] PRAGMA command to execute (without the "PRAGMA" keyword)
202
- # @return [ActiveRecord::Result] Result set with the PRAGMA value
203
- # @raise [StandardError] If the query is invalid or cannot be executed
204
- def execute_sqlite_pragma(pragma)
205
- @query_executor.execute_sqlite_pragma(pragma)
206
- end
91
+ ## -- Delegator
207
92
 
208
93
  # Analyze query patterns and return performance recommendations
209
94
  # @param table_name [String] Name of the table
@@ -223,112 +108,64 @@ module Dbviewer
223
108
  def apply_column_filters(query, table_name, column_filters)
224
109
  return query unless column_filters.present?
225
110
 
226
- # Create a copy of column_filters to modify without affecting the original
227
- filters = column_filters.dup
228
-
229
- # First check if we have a datetime range filter for created_at
230
- if filters["created_at"].present? &&
231
- filters["created_at_end"].present? &&
232
- column_exists?(table_name, "created_at")
233
-
234
- # Handle datetime range for created_at
235
- begin
236
- start_datetime = Time.parse(filters["created_at"].to_s)
237
- end_datetime = Time.parse(filters["created_at_end"].to_s)
238
-
239
- # Make sure end_datetime is at the end of the day/minute if it doesn't have time component
240
- if end_datetime.to_s.match(/00:00:00/)
241
- end_datetime = end_datetime.end_of_day
242
- end
243
-
244
- Rails.logger.info("[DBViewer] Applying date range filter on #{table_name}.created_at: #{start_datetime} to #{end_datetime}")
245
-
246
- # Use qualified column name for tables with schema
247
- column_name = "#{table_name}.created_at"
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
111
+ column_filters.each do |column, value|
112
+ query = apply_single_column_filter(query, table_name, column, value, column_filters)
271
113
  end
272
114
 
273
- # Apply remaining simple column filters
274
- filters.each do |column, value|
275
- next unless column_exists?(table_name, column)
115
+ query
116
+ end
276
117
 
277
- # Check if this is a column operator field
278
- if column.end_with?("_operator")
279
- next # Skip operator fields - they're processed with their column
280
- end
118
+ # Apply a single column filter to a query
119
+ # @param query [ActiveRecord::Relation] The query to apply the filter to
120
+ # @param table_name [String] Name of the table
121
+ # @param column [String] The column name
122
+ # @param value [String] The filter value
123
+ # @param column_filters [Hash] All column filters for accessing operator
124
+ # @return [ActiveRecord::Relation] The filtered query
125
+ def apply_single_column_filter(query, table_name, column, value, column_filters)
126
+ return query unless column_exists?(table_name, column)
127
+ return query if column.end_with?("_operator")
281
128
 
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
129
+ operator = column_filters["#{column}_operator"]
295
130
 
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
131
+ if special_operators(query)[operator]
132
+ return special_operators(query)[operator].call(column)
133
+ end
134
+ return query if value.blank?
135
+
136
+ if operator && operator_handlers(query)[operator]
137
+ return operator_handlers(query)[operator].call(column, value)
138
+ else
139
+ # Default to eq no specific operator is provided
140
+ return query.where("#{column} = ?", value)
327
141
  end
328
142
 
329
143
  query
330
144
  end
331
145
 
146
+ def special_operators(query)
147
+ {
148
+ "is_null" => ->(column) { query.where("#{column} IS NULL") },
149
+ "is_not_null" => ->(column) { query.where("#{column} IS NOT NULL") }
150
+ }
151
+ end
152
+
153
+ def operator_handlers(query)
154
+ {
155
+ "contains" => ->(column, value) { query.where("#{column} LIKE ?", "%#{value}%") },
156
+ "not_contain" => ->(column, value) { query.where("#{column} NOT LIKE ?", "%#{value}%") },
157
+ "starts_with" => ->(column, value) { query.where("#{column} LIKE ?", "#{value}%") },
158
+ "ends_with" => ->(column, value) { query.where("#{column} LIKE ?", "%#{value}") },
159
+ "equals" => ->(column, value) { query.where(column.to_sym => value) },
160
+ "eq" => ->(column, value) { query.where("#{column} = ?", value) },
161
+ "gte" => ->(column, value) { query.where("#{column} >= ?", value) },
162
+ "lte" => ->(column, value) { query.where("#{column} <= ?", value) },
163
+ "gt" => ->(column, value) { query.where("#{column} > ?", value) },
164
+ "lt" => ->(column, value) { query.where("#{column} < ?", value) },
165
+ "neq" => ->(column, value) { query.where("#{column} != ?", value) }
166
+ }
167
+ end
168
+
332
169
  # Helper methods delegated to managers
333
170
  def get_model_for(table_name)
334
171
  @dynamic_model_factory.get_model_for(table_name)
@@ -345,6 +182,39 @@ module Dbviewer
345
182
  def primary_key(table_name)
346
183
  @table_metadata_manager.primary_key(table_name)
347
184
  end
185
+
186
+ # Convert ActiveRecord::Relation to a standard result format
187
+ # @param records [ActiveRecord::Relation] Records to convert
188
+ # @param column_names [Array<String>] Column names
189
+ # @return [ActiveRecord::Result] Result set with columns and rows
190
+ def to_result_set(records, column_names)
191
+ rows = records.map do |record|
192
+ column_names.map do |col|
193
+ # Handle serialized attributes
194
+ value = record[col]
195
+ serialize_if_needed(value)
196
+ end
197
+ end
198
+
199
+ ActiveRecord::Result.new(column_names, rows)
200
+ rescue => e
201
+ Rails.logger.error("[DBViewer] Error converting to result set: #{e.message}")
202
+ ActiveRecord::Result.new([], [])
203
+ end
204
+
205
+ # Serialize complex objects for display
206
+ # @param value [Object] Value to serialize
207
+ # @return [String, Object] Serialized value or original value
208
+ def serialize_if_needed(value)
209
+ case value
210
+ when Hash, Array
211
+ value.to_json rescue value.to_s
212
+ when Time, Date, DateTime
213
+ value.to_s
214
+ else
215
+ value
216
+ end
217
+ end
348
218
  end
349
219
  end
350
220
  end
@@ -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
- next unless Rails.env.development?
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::SqlValidator.validate!(sql.to_s)
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
@@ -53,41 +53,6 @@ module Dbviewer
53
53
  Rails.logger.error("[DBViewer] SQLite pragma error: #{e.message} for pragma: #{pragma}")
54
54
  raise e
55
55
  end
56
-
57
- # Convert ActiveRecord::Relation to a standard result format
58
- # @param records [ActiveRecord::Relation] Records to convert
59
- # @param column_names [Array<String>] Column names
60
- # @return [ActiveRecord::Result] Result set with columns and rows
61
- def to_result_set(records, column_names)
62
- rows = records.map do |record|
63
- column_names.map do |col|
64
- # Handle serialized attributes
65
- value = record[col]
66
- serialize_if_needed(value)
67
- end
68
- end
69
-
70
- ActiveRecord::Result.new(column_names, rows)
71
- rescue => e
72
- Rails.logger.error("[DBViewer] Error converting to result set: #{e.message}")
73
- ActiveRecord::Result.new([], [])
74
- end
75
-
76
- private
77
-
78
- # Serialize complex objects for display
79
- # @param value [Object] Value to serialize
80
- # @return [String, Object] Serialized value or original value
81
- def serialize_if_needed(value)
82
- case value
83
- when Hash, Array
84
- value.to_json rescue value.to_s
85
- when Time, Date, DateTime
86
- value.to_s
87
- else
88
- value
89
- end
90
- end
91
56
  end
92
57
  end
93
58
  end
@@ -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