From 8bcc4fd9757f53029bc907d161d5653d1dd5db5e Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Wed, 8 Jul 2026 11:43:30 +0300 Subject: [PATCH] security(mysql): replace string concatenation with parameterized queries in schema introspection (#241) --- CHANGELOG.md | 1 + lib/core/database/mysql_connection.dart | 20 ++--- test/core/database/mysql_connection_test.dart | 89 +++++++++++++++++++ 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6776a31e..a2781fe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **SQLite / RETURNING clause support (#243)** — support RETURNING clauses for INSERT, UPDATE, and DELETE DML queries in the SQLite database driver, returning the resulting rows to the client. +- **Security / MySQL Injection Fix (#241)** — replaced manual escaping and string concatenation in schema introspection methods (`listViews`, `listColumnNames`, `listTables`) in the MySQL database driver with parameterized queries using parameter binding. ## [0.4.7-a] - 2026-06-22 diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index df453aeb..5c4c4c6f 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -76,9 +76,7 @@ class MysqlConnection { bool get _usesConnectionString => connectionString != null && connectionString!.trim().isNotEmpty; - static String _escapeSqlString(String s) { - return s.replaceAll(r'\', r'\\').replaceAll("'", "''"); - } + /// MySQL identifier quoting (backticks). static String quoteIdentifier(String id) { @@ -284,11 +282,11 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final s = _escapeSqlString(schema); final rs = await execute( 'SELECT TABLE_NAME FROM information_schema.TABLES ' - "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'VIEW' " + "WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = 'VIEW' " 'ORDER BY TABLE_NAME', + {'schema': schema}, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } @@ -301,12 +299,14 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final d = _escapeSqlString(database); - final t = _escapeSqlString(table); final rs = await execute( 'SELECT COLUMN_NAME FROM information_schema.COLUMNS ' - "WHERE TABLE_SCHEMA = '$d' AND TABLE_NAME = '$t' " + "WHERE TABLE_SCHEMA = :database AND TABLE_NAME = :table " 'ORDER BY ORDINAL_POSITION', + { + 'database': database, + 'table': table, + }, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } @@ -316,11 +316,11 @@ class MysqlConnection { if (!isConnected || _conn == null) { throw StateError('Not connected to MySQL'); } - final s = _escapeSqlString(schema); final rs = await execute( 'SELECT TABLE_NAME FROM information_schema.TABLES ' - "WHERE TABLE_SCHEMA = '$s' AND TABLE_TYPE = 'BASE TABLE' " + "WHERE TABLE_SCHEMA = :schema AND TABLE_TYPE = 'BASE TABLE' " 'ORDER BY TABLE_NAME', + {'schema': schema}, ); return rs.rows.map((r) => r.colAt(0)!).toList(); } diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart index 848a8e08..70f19680 100644 --- a/test/core/database/mysql_connection_test.dart +++ b/test/core/database/mysql_connection_test.dart @@ -39,4 +39,93 @@ void main() { ); }); }); + + group('MysqlConnection when not connected', () { + late MysqlConnection conn; + + setUp(() { + conn = MysqlConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + }); + + test('execute throws StateError', () { + expect( + () => conn.execute('SELECT 1'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listDatabases throws StateError', () { + expect( + () => conn.listDatabases(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listViews throws StateError', () { + expect( + () => conn.listViews(schema: 'db'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listColumnNames throws StateError', () { + expect( + () => conn.listColumnNames(database: 'db', table: 'tbl'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('listTables throws StateError', () { + expect( + () => conn.listTables(schema: 'db'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('serverVersion throws StateError', () { + expect( + () => conn.serverVersion(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + + test('serverStats throws StateError', () { + expect( + () => conn.serverStats(), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected to MySQL'), + )), + ); + }); + }); }