From eb2aeb4a5aa977ab6296d0ab29d96eb8fff245db Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 10 Jul 2026 15:50:54 +0300 Subject: [PATCH] fix(mysql): harden custom SELECT validation in table browser (#274) Tokenize SQL outside string literals and comments before splitting on semicolons so valid queries with ';' in literals are not blocked. Reject INTO OUTFILE, FOR UPDATE, and other write-side constructs when they appear outside masked literals/comments. --- lib/features/mysql/mysql_table_utils.dart | 291 +++++++++++++++++- .../mysql/mysql_table_utils_test.dart | 64 ++++ 2 files changed, 352 insertions(+), 3 deletions(-) diff --git a/lib/features/mysql/mysql_table_utils.dart b/lib/features/mysql/mysql_table_utils.dart index 771dced1..045db086 100644 --- a/lib/features/mysql/mysql_table_utils.dart +++ b/lib/features/mysql/mysql_table_utils.dart @@ -6,7 +6,292 @@ bool isAllowedMysqlSelectQuery(String sql) { if (!lower.startsWith('select') && !lower.startsWith('with')) { return false; } - // Reject naive multi-statement (semicolon-separated) scripts. - final parts = t.split(';').where((s) => s.trim().isNotEmpty).toList(); - return parts.length <= 1; + + final statements = _splitMysqlStatements(t) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty && !_mysqlFragmentIsOnlyComments(s)) + .toList(); + if (statements.length != 1) return false; + + return !_mysqlSelectQueryHasBlockedConstructs(statements.first); +} + +List _splitMysqlStatements(String sql) { + final statements = []; + final buffer = StringBuffer(); + var inSingleQuote = false; + var inDoubleQuote = false; + var inBacktick = false; + var inLineComment = false; + var inBlockComment = false; + + for (var i = 0; i < sql.length; i++) { + final c = sql[i]; + final next = i + 1 < sql.length ? sql[i + 1] : ''; + + if (inLineComment) { + buffer.write(c); + if (c == '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + buffer.write(c); + if (c == '*' && next == '/') { + buffer.write(next); + inBlockComment = false; + i++; + } + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick) { + if (c == '-' && next == '-') { + inLineComment = true; + buffer.write(c); + buffer.write(next); + i++; + continue; + } + if (c == '#') { + inLineComment = true; + buffer.write(c); + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + buffer.write(c); + buffer.write(next); + i++; + continue; + } + } + + if (!inDoubleQuote && !inBacktick && c == "'") { + if (inSingleQuote && next == "'") { + buffer.write(c); + buffer.write(next); + i++; + continue; + } + inSingleQuote = !inSingleQuote; + buffer.write(c); + continue; + } + + if (!inSingleQuote && !inBacktick && c == '"') { + if (inDoubleQuote && next == '"') { + buffer.write(c); + buffer.write(next); + i++; + continue; + } + inDoubleQuote = !inDoubleQuote; + buffer.write(c); + continue; + } + + if (!inSingleQuote && !inDoubleQuote && c == '`') { + inBacktick = !inBacktick; + buffer.write(c); + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick && c == ';') { + statements.add(buffer.toString()); + buffer.clear(); + continue; + } + + buffer.write(c); + } + + statements.add(buffer.toString()); + return statements; +} + +bool _mysqlFragmentIsOnlyComments(String sql) { + var inSingleQuote = false; + var inDoubleQuote = false; + var inBacktick = false; + var inLineComment = false; + var inBlockComment = false; + var hasCode = false; + + for (var i = 0; i < sql.length; i++) { + final c = sql[i]; + final next = i + 1 < sql.length ? sql[i + 1] : ''; + + if (inLineComment) { + if (c == '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + i++; + } + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick) { + if (c == '-' && next == '-') { + inLineComment = true; + i++; + continue; + } + if (c == '#') { + inLineComment = true; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + i++; + continue; + } + } + + if (!inDoubleQuote && !inBacktick && c == "'") { + if (inSingleQuote && next == "'") { + i++; + continue; + } + inSingleQuote = !inSingleQuote; + continue; + } + + if (!inSingleQuote && !inBacktick && c == '"') { + if (inDoubleQuote && next == '"') { + i++; + continue; + } + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (!inSingleQuote && !inDoubleQuote && c == '`') { + inBacktick = !inBacktick; + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick && !_isWhitespace(c)) { + hasCode = true; + break; + } + } + + return !hasCode; +} + +bool _mysqlSelectQueryHasBlockedConstructs(String sql) { + final masked = _maskMysqlLiteralsAndComments(sql).toLowerCase(); + final blocked = [ + RegExp(r'\binto\s+outfile\b'), + RegExp(r'\binto\s+dumpfile\b'), + RegExp(r'\bfor\s+update\b'), + RegExp(r'\block\s+in\s+share\s+mode\b'), + RegExp( + r'\b(insert|update|delete|drop|truncate|alter|create|grant|revoke|call|execute|replace|rename)\b', + ), + ]; + for (final pattern in blocked) { + if (pattern.hasMatch(masked)) return true; + } + return false; +} + +String _maskMysqlLiteralsAndComments(String sql) { + final buffer = StringBuffer(); + var inSingleQuote = false; + var inDoubleQuote = false; + var inBacktick = false; + var inLineComment = false; + var inBlockComment = false; + + for (var i = 0; i < sql.length; i++) { + final c = sql[i]; + final next = i + 1 < sql.length ? sql[i + 1] : ''; + + if (inLineComment) { + buffer.write(' '); + if (c == '\n') { + buffer.write('\n'); + inLineComment = false; + } + continue; + } + if (inBlockComment) { + buffer.write(c == '\n' ? '\n' : ' '); + if (c == '*' && next == '/') { + buffer.write(' '); + inBlockComment = false; + i++; + } + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick) { + if (c == '-' && next == '-') { + inLineComment = true; + buffer.write(' '); + buffer.write(' '); + i++; + continue; + } + if (c == '#') { + inLineComment = true; + buffer.write(' '); + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + buffer.write(' '); + buffer.write(' '); + i++; + continue; + } + } + + if (!inDoubleQuote && !inBacktick && c == "'") { + if (inSingleQuote && next == "'") { + buffer.write(' '); + buffer.write(' '); + i++; + continue; + } + inSingleQuote = !inSingleQuote; + buffer.write(' '); + continue; + } + + if (!inSingleQuote && !inBacktick && c == '"') { + if (inDoubleQuote && next == '"') { + buffer.write(' '); + buffer.write(' '); + i++; + continue; + } + inDoubleQuote = !inDoubleQuote; + buffer.write(' '); + continue; + } + + if (!inSingleQuote && !inDoubleQuote && c == '`') { + inBacktick = !inBacktick; + buffer.write(' '); + continue; + } + + if (inSingleQuote || inDoubleQuote || inBacktick) { + buffer.write(' '); + continue; + } + + buffer.write(c); + } + + return buffer.toString(); +} + +bool _isWhitespace(String c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; } diff --git a/test/features/mysql/mysql_table_utils_test.dart b/test/features/mysql/mysql_table_utils_test.dart index 6c916591..ab36baa3 100644 --- a/test/features/mysql/mysql_table_utils_test.dart +++ b/test/features/mysql/mysql_table_utils_test.dart @@ -19,5 +19,69 @@ void main() { test('rejects empty', () { expect(isAllowedMysqlSelectQuery(''), isFalse); }); + + test('allows semicolon inside single-quoted string', () { + expect( + isAllowedMysqlSelectQuery( + "SELECT * FROM logs WHERE message = 'error; system halted'", + ), + isTrue, + ); + }); + + test('allows semicolon inside double-quoted string', () { + expect( + isAllowedMysqlSelectQuery( + 'SELECT * FROM users WHERE status = "active; verified"', + ), + isTrue, + ); + }); + + test('allows trailing semicolon on single statement', () { + expect(isAllowedMysqlSelectQuery('SELECT * FROM t;'), isTrue); + }); + + test('allows trailing line comment after semicolon', () { + expect(isAllowedMysqlSelectQuery('SELECT * FROM t; -- done'), isTrue); + }); + + test('allows semicolon inside block comment', () { + expect( + isAllowedMysqlSelectQuery( + 'SELECT 1 /* note; ignored */ FROM t', + ), + isTrue, + ); + }); + + test('rejects INTO OUTFILE', () { + expect( + isAllowedMysqlSelectQuery( + "SELECT * FROM users INTO OUTFILE '/tmp/users.txt'", + ), + isFalse, + ); + }); + + test('rejects FOR UPDATE', () { + expect( + isAllowedMysqlSelectQuery('SELECT * FROM accounts FOR UPDATE'), + isFalse, + ); + }); + + test('allows INTO OUTFILE inside string literal', () { + expect( + isAllowedMysqlSelectQuery( + "SELECT * FROM docs WHERE body = 'INTO OUTFILE example'", + ), + isTrue, + ); + }); + + test('rejects non-SELECT statements', () { + expect(isAllowedMysqlSelectQuery('DELETE FROM t'), isFalse); + }); }); }