Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 288 additions & 3 deletions lib/features/mysql/mysql_table_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _splitMysqlStatements(String sql) {
final statements = <String>[];
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';
}
64 changes: 64 additions & 0 deletions test/features/mysql/mysql_table_utils_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}
Loading