diff --git a/lib/core/mcp/mcp_sql_guard.dart b/lib/core/mcp/mcp_sql_guard.dart index caa0da5f..17ce1c19 100644 --- a/lib/core/mcp/mcp_sql_guard.dart +++ b/lib/core/mcp/mcp_sql_guard.dart @@ -47,7 +47,9 @@ abstract final class McpSqlGuard { r'PG_READ_FILE|PG_READ_BINARY_FILE|PG_LS_DIR|PG_STAT_FILE|LO_\w*|' r'QUERY_TO_XML\w*|CURSOR_TO_XML|TABLE_TO_XML\w*|PG_(?:TRY_)?ADVISORY_\w*|PG_SLEEP\w*|' r'NEXTVAL|SETVAL|DBLINK\w*|SET_CONFIG|LOAD_FILE|LOAD_EXTENSION|WRITEFILE|' - r'READFILE|FSDIR)\s*\(', + r'READFILE|FSDIR|SLEEP|BENCHMARK|GET_LOCK|RELEASE_LOCK|RELEASE_ALL_LOCKS|IS_FREE_LOCK|' + r'PG_STAT_RESET\w*|PG_SWITCH_WAL|PG_CREATE_RESTORE_POINT|PG_LOGICAL_EMIT_MESSAGE|' + r'PG_PROMOTE|PG_DROP_REPLICATION_SLOT)\s*\(', ); /// SQLite pragmas that only read the schema. @@ -114,7 +116,9 @@ abstract final class McpSqlGuard { if (first == null || !_readStarts.contains(first)) { return const McpSqlRefusal('read_only_only', 'Only read-only queries are allowed (SELECT, WITH, EXPLAIN, SHOW, DESCRIBE).'); } - if (_forbiddenFunctions.hasMatch(upper)) { + // `"nextval"('s')` is the same call in PostgreSQL; the stripper keeps + // quoted identifiers, so drop the quotes before looking for the name. + if (_forbiddenFunctions.hasMatch(upper.replaceAll(RegExp(r'["`]'), ''))) { return const McpSqlRefusal('function_not_allowed', 'This query calls a server function that is not allowed over MCP.'); } if (first == 'EXPLAIN') { diff --git a/test/core/mcp/mcp_sql_guard_test.dart b/test/core/mcp/mcp_sql_guard_test.dart index d9392012..74eeb527 100644 --- a/test/core/mcp/mcp_sql_guard_test.dart +++ b/test/core/mcp/mcp_sql_guard_test.dart @@ -99,6 +99,24 @@ void main() { refused("SELECT setval('s', 10)"); }); + test('a denylisted function is refused quoted, qualified and spaced (#1769)', + () { + refused('SELECT pg_catalog."pg_terminate_backend"(123)'); + refused('''SELECT "nextval"('s')'''); + refused('SELECT "pg_sleep" (60)'); + refused('SELECT pg_catalog.pg_sleep (60)'); + refused('SELECT pg_sleep /* c */ (60)'); + refused('SELECT pg_stat_reset()'); + refused("SELECT pg_create_restore_point('x')"); + refused('SELECT pg_promote()'); + refused('SELECT SLEEP(5)', SqlDialect.mysql); + refused("SELECT BENCHMARK(1000000, MD5('a'))", SqlDialect.mysql); + refused("SELECT GET_LOCK('l', 10)", SqlDialect.mysql); + refused("SELECT RELEASE_LOCK('l')", SqlDialect.mysql); + refused("SELECT IS_FREE_LOCK('l')", SqlDialect.mysql); + refused('SELECT `sleep`(5)', SqlDialect.mysql); + }); + test('EXPLAIN of a SELECT that calls REPLACE() is allowed (#1565)', () { allowed("EXPLAIN SELECT REPLACE(name, 'a', 'b') FROM users"); refused("EXPLAIN REPLACE INTO users VALUES (1)", SqlDialect.mysql);