Mark MySQL/PostgreSQL database query as destructive and drop SQL verb blocklists (#3202)

* Fixed input validation issue in MySql and Postgres

* Added MySql validation test cases

* Fix Postgres SQL validator: tokenize string-stripped content and add regex timeout

- Tokenize withoutStrings instead of decodedForValidation to avoid false
  positives when dangerous keywords appear inside string literals
  (e.g., SELECT 'drop table' AS msg)
- Only strip U&'...' (string literals) not U&"..." (identifiers) per
  PostgreSQL spec, so obfuscated function names in identifiers are still
  caught by tokenization
- Add RegexHelper.DefaultRegexTimeout to DecodeUnicodeEscapes for
  consistency with other regex usage in the validator
- Fix whitespace formatting in SqlQueryValidatorTests.cs closing brace
- Move U&'...' test case to safe queries (string literals cannot invoke
  functions per PostgreSQL spec)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Marked MySql and Postgres query commands as destructive, not idempotent, and not read-only

* Block the uescape command in validator

* Remove SQL verb/keyword blocklists from MySQL and Postgres query validation

The MySQL and Postgres database query tools are now marked destructive, so
rejecting statements by SQL verb or dangerous-identifier blocklist is no longer
meaningful - the signed-in user's database permissions are the authority.

Validation is reduced to structural checks: empty input, max length, SQL
comments, and multiple/stacked statements. The Unicode/National-string
de-obfuscation logic (which only existed to feed the blocklists) is removed,
along with the unfinished UESCAPE check that did not compile.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Reword changelog entries to fix cspell failure

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Move MySQL/Postgres query tools to a destructive consolidated tool

The consolidated tool discovery strategy requires every mapped command's
ToolMetadata to match its consolidated tool exactly. Marking the query
commands destructive left them mismatched against the read-only
get_azure_databases_details tool, which made the server fail to start in
consolidated mode.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Test consolidated tool metadata mappings

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Vinay Gera <vigera@microsoft.com>
This commit is contained in:
authored and GitHub committed 2026-09-11 15:27:05 -07:00
1 parent 75d699813f
commit 1fa503200a
15 files changed
+192 -402

No files matched your search

@@ -0,0 +1,6 @@
pr: 3202
changes: # Required
- section: "Breaking Changes" # Required - Valid sections: "New Features", "Breaking Changes", "Bug Fixes", "Other Changes"
description: "Updated the MySQL `database query` tool to be destructive, non-idempotent, and non-read-only. The tool no longer rejects statements based on SQL verb or keyword filtering; the signed-in user's database permissions determine what a statement may do. Empty, oversized, commented, and stacked statements are still rejected."
- section: "Breaking Changes"
description: "In consolidated mode, `mysql_database_query` and `postgres_database_query` moved out of the read-only `get_azure_databases_details` tool into a new `execute_azure_database_query` tool that reflects their destructive metadata."
@@ -0,0 +1,4 @@
pr: 3202
changes: # Required
- section: "Breaking Changes" # Required - Valid sections: "New Features", "Breaking Changes", "Bug Fixes", "Other Changes"
description: "Updated the PostgreSQL `database query` tool to be destructive, non-idempotent, and non-read-only. The tool no longer rejects statements based on SQL verb or dangerous identifier filtering; the signed-in user's database permissions determine what a statement may do. Empty, oversized, commented, and stacked statements are still rejected."
@@ -2587,8 +2587,8 @@ azmcp mysql list --subscription <subscription> \
[--server <server>] \
[--database <database>]
# Executes a SELECT query on a MySQL Database. The query must start with SELECT and cannot contain any destructive SQL operations for security reasons.
# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
# Executes a SQL statement on a MySQL database. Only a single statement is executed per call; SQL comments and stacked statements are rejected. The signed-in user's database permissions determine what the statement may do.
# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp mysql database query --subscription <subscription> \
--resource-group <resource-group> \
--user <user> \
@@ -2644,8 +2644,8 @@ azmcp postgres list --subscription <subscription> \
[--database <database>] \
[--schema <schema>]
# Execute a query on a PostgreSQL database
# ❌ Destructive | ✅ Idempotent | ❌ OpenWorld | ✅ ReadOnly | ❌ Secret | ❌ LocalRequired
# Execute a query on a PostgreSQL database. Only a single statement is executed per call; SQL comments and stacked statements are rejected. The signed-in user's database permissions determine what the statement may do.
# ✅ Destructive | ❌ Idempotent | ❌ OpenWorld | ❌ ReadOnly | ❌ Secret | ❌ LocalRequired
azmcp postgres database query --user <user> \
--server <server> \
--database <database> \
@@ -724,7 +724,7 @@
},
{
"name": "get_azure_databases_details",
"description": "Comprehensive Azure database management tool for MySQL, PostgreSQL, SQL Database, SQL Server, and Cosmos DB. List and query databases, retrieve server configurations and parameters, explore table schemas, execute database queries, manage Cosmos DB containers and items, list and view detailed information about SQL servers, and view database server details across all Azure database services.",
"description": "Comprehensive Azure database management tool for MySQL, PostgreSQL, SQL Database, SQL Server, and Cosmos DB. List databases, retrieve server configurations and parameters, explore table schemas, query and manage Cosmos DB containers and items, list and view detailed information about SQL servers, and view database server details across all Azure database services.",
"toolMetadata": {
"destructive": {
"value": false,
@@ -753,12 +753,10 @@
},
"mappedToolList": [
"mysql_list",
"mysql_database_query",
"mysql_server_config_get",
"mysql_server_param_get",
"mysql_table_schema_get",
"postgres_list",
"postgres_database_query",
"postgres_server_config_get",
"postgres_server_param_get",
"postgres_table_schema_get",
@@ -773,6 +771,40 @@
"cosmos_database_container_item_vector-search"
]
},
{
"name": "execute_azure_database_query",
"description": "Execute an arbitrary SQL statement against an Azure Database for MySQL or Azure Database for PostgreSQL flexible server database. Supports reads such as SELECT as well as statements that modify data or schema, including INSERT, UPDATE, DELETE, and DDL. The statement runs with the permissions of the signed-in user, so what it is allowed to do is determined by that user's database privileges.",
"toolMetadata": {
"destructive": {
"value": true,
"description": "This tool may delete or modify existing resources in its environment."
},
"idempotent": {
"value": false,
"description": "Running this operation multiple times with the same arguments may have additional effects or produce different results."
},
"openWorld": {
"value": false,
"description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities."
},
"readOnly": {
"value": false,
"description": "This tool may modify its environment by creating, updating, or deleting data."
},
"secret": {
"value": false,
"description": "This tool does not handle sensitive or secret information."
},
"localRequired": {
"value": false,
"description": "This tool is available in both local and remote server modes."
}
},
"mappedToolList": [
"mysql_database_query",
"postgres_database_query"
]
},
{
"name": "create_azure_sql_databases_and_servers",
"description": "Create new Azure SQL databases and SQL servers with configurable performance tiers and settings.",
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Mcp.Core.Areas.Server.Commands.Discovery;
using Microsoft.Mcp.Core.Commands;
using Xunit;
namespace Azure.Mcp.Server.Tests.Infrastructure;
public sealed class ConsolidatedToolMetadataTests()
{
[Fact]
public async Task ConsolidatedTools_MappedCommands_HaveMatchingMetadata()
{
ServiceCollection serviceCollection = new();
Program.ConfigureServices(serviceCollection);
await using var services = serviceCollection.BuildServiceProvider();
var commandFactory = services.GetRequiredService<ICommandFactory>();
var definitionProvider = services.GetRequiredService<IConsolidatedToolDefinitionProvider>();
var mismatches = new List<string>();
foreach (var consolidatedTool in definitionProvider.GetToolDefinitions())
{
foreach (var commandName in consolidatedTool.MappedToolList)
{
if (!commandFactory.AllCommands.TryGetValue(commandName, out var command))
{
// Definitions can include commands that are not registered in this server build.
continue;
}
if (!MetadataMatches(command.Metadata, consolidatedTool.ToolMetadata))
{
mismatches.Add(
$"Command '{commandName}' mapped to consolidated tool '{consolidatedTool.Name}' has metadata " +
$"{FormatMetadata(command.Metadata)}; expected {FormatMetadata(consolidatedTool.ToolMetadata)}.");
}
}
}
Assert.True(
mismatches.Count == 0,
$"Found {mismatches.Count} invalid consolidated tool mapping(s):{Environment.NewLine}" +
string.Join(Environment.NewLine, mismatches));
}
private static bool MetadataMatches(ToolMetadata actual, ToolMetadata expected) =>
actual.Destructive == expected.Destructive &&
actual.Idempotent == expected.Idempotent &&
actual.OpenWorld == expected.OpenWorld &&
actual.ReadOnly == expected.ReadOnly &&
actual.Secret == expected.Secret &&
actual.LocalRequired == expected.LocalRequired;
private static string FormatMetadata(ToolMetadata metadata) =>
$"[Destructive={metadata.Destructive}, Idempotent={metadata.Idempotent}, " +
$"OpenWorld={metadata.OpenWorld}, ReadOnly={metadata.ReadOnly}, " +
$"Secret={metadata.Secret}, LocalRequired={metadata.LocalRequired}]";
}
@@ -15,12 +15,12 @@ namespace Azure.Mcp.Tools.MySql.Commands.Database;
Id = "b73afaa5-4c3f-41e8-9ef3-c54e75215a97",
Name = "query",
Title = "Query MySQL Database",
Description = "Executes a safe, read-only SQL SELECT query against a database on Azure Database for MySQL Flexible Server. Use this tool to explore or retrieve table data without modifying it. Rejects non-SELECT statements (INSERT/UPDATE/DELETE/REPLACE/MERGE/TRUNCATE/ALTER/CREATE/DROP), multi-statements, comments hiding writes, transaction control (BEGIN/COMMIT/ROLLBACK), INTO OUTFILE, and other destructive keywords. Only a single SELECT is executed to ensure data integrity. Best practices: List needed columns (avoid SELECT *), add WHERE filters, use LIMIT/OFFSET for paging, ORDER BY for deterministic results, and avoid unnecessary sensitive data. Example: SELECT id, name, status FROM customers WHERE status = 'Active' ORDER BY name LIMIT 50;",
Description = "Executes a SQL statement against a database on Azure Database for MySQL Flexible Server. Use this tool to explore or retrieve table data, or to modify data when the signed-in user has permission to do so. Only a single statement is executed per call; SQL comments and stacked statements are rejected. Best practices: List needed columns (avoid SELECT *), add WHERE filters, use LIMIT/OFFSET for paging, ORDER BY for deterministic results, and avoid unnecessary sensitive data. Example: SELECT id, name, status FROM customers WHERE status = 'Active' ORDER BY name LIMIT 50;",
OperationPlane = ToolOperationPlane.Data,
Destructive = false,
Idempotent = true,
Destructive = true,
Idempotent = false,
OpenWorld = false,
ReadOnly = true,
ReadOnly = false,
Secret = false,
LocalRequired = false)]
public sealed class DatabaseQueryCommand(ILogger<DatabaseQueryCommand> logger, IMySqlService mysqlService, ISubscriptionResolver subscriptionResolver)
@@ -21,63 +21,10 @@ public sealed class MySqlService(IAzureService azureService)
// Maximum allowed query length in characters to prevent oversized inputs
private const int MaxQueryLengthChars = 10_000;
// Static arrays for security validation - initialized once per class
private static readonly string[] DangerousKeywords =
[
// Data manipulation that could be harmful
"DROP", "DELETE", "TRUNCATE", "ALTER", "CREATE", "INSERT", "UPDATE",
// Set operations that can be used for data exfiltration
"UNION", "INTERSECT", "EXCEPT",
// Administrative operations
"GRANT", "REVOKE", "SET", "RESET", "KILL", "SHUTDOWN", "RESTART",
// Information disclosure
"SHOW MASTER", "SHOW SLAVE", "SHOW BINARY", "SHOW BINLOG",
// System operations
"LOAD DATA", "OUTFILE", "DUMPFILE", "LOAD_FILE", "INTO OUTFILE",
// User/privilege management
"CREATE USER", "DROP USER", "ALTER USER", "RENAME USER",
// Database structure changes
"CREATE DATABASE", "DROP DATABASE", "CREATE SCHEMA", "DROP SCHEMA",
// Stored procedures and functions
"CREATE PROCEDURE", "DROP PROCEDURE", "CREATE FUNCTION", "DROP FUNCTION",
// Triggers and events
"CREATE TRIGGER", "DROP TRIGGER", "CREATE EVENT", "DROP EVENT",
// Views that could modify data
"CREATE VIEW", "DROP VIEW",
// Index operations
"CREATE INDEX", "DROP INDEX",
// Table operations
"CREATE TABLE", "DROP TABLE", "RENAME TABLE",
// Lock operations
"LOCK TABLES", "UNLOCK TABLES",
// Transaction control in unsafe contexts
"START TRANSACTION", "BEGIN", "COMMIT", "ROLLBACK",
// System variables
"SET GLOBAL", "SET SESSION", "SET SQL_MODE"
];
private static readonly string[] ObfuscationFunctions =
[
"CHAR", "CHR", "ASCII", "ORD", "HEX", "UNHEX", "CONV",
"CONVERT", "CAST", "BINARY", "CONCAT_WS", "MAKE_SET",
"ELT", "FIELD", "FIND_IN_SET", "EXPORT_SET", "LOAD_FILE",
"FROM_BASE64", "TO_BASE64", "COMPRESS", "UNCOMPRESS",
"AES_ENCRYPT", "AES_DECRYPT", "DES_ENCRYPT", "DES_DECRYPT",
"ENCODE", "DECODE", "PASSWORD", "OLD_PASSWORD"
];
// Pre-compiled regex patterns for word-boundary keyword matching
// Pre-compiled regex used to detect multiple / stacked statements
private static readonly Regex s_multipleStatementsPattern =
RegexHelper.CreateRegex(@";\s*\w", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex DangerousKeywordsPattern = RegexHelper.CreateRegex(
@"\b(" + string.Join("|", DangerousKeywords.Select(Regex.Escape)) + @")\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static readonly Regex ObfuscationFunctionsPattern = RegexHelper.CreateRegex(
@"\b(" + string.Join("|", ObfuscationFunctions.Select(Regex.Escape)) + @")\s*\(",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private async Task<string> GetEntraIdAccessTokenAsync(CancellationToken cancellationToken)
{
var tokenCredential = await GetCredential(null, cancellationToken);
@@ -154,6 +101,11 @@ public sealed class MySqlService(IAzureService azureService)
return builder.ConnectionString;
}
/// <summary>
/// Performs lightweight structural validation of a query. This does not restrict which SQL verbs may be
/// executed; the caller's database permissions are the authority on what is allowed. Validation is limited
/// to rejecting empty or oversized input, SQL comments, and multiple / stacked statements.
/// </summary>
internal static void ValidateQuerySafety(string query)
{
if (string.IsNullOrWhiteSpace(query))
@@ -191,35 +143,7 @@ public sealed class MySqlService(IAzureService azureService)
if (s_multipleStatementsPattern.IsMatch(cleanedQuery))
{
throw new InvalidOperationException("Multiple SQL statements are not allowed. Use only a single SELECT statement.");
}
// List of dangerous SQL keywords that should be blocked (word-boundary matching)
var keywordMatch = DangerousKeywordsPattern.Match(cleanedQuery);
if (keywordMatch.Success)
{
throw new InvalidOperationException($"Query contains dangerous keyword '{keywordMatch.Value.ToUpperInvariant()}' which is not allowed for security reasons.");
}
// Check for character conversion functions that may be used for obfuscation
var funcMatch = ObfuscationFunctionsPattern.Match(cleanedQuery);
if (funcMatch.Success)
{
throw new InvalidOperationException($"Character conversion and obfuscation functions like '{funcMatch.Groups[1].Value.ToUpperInvariant()}' are not allowed for security reasons.");
}
// Additional validation: Only allow SELECT statements
var trimmedQuery = cleanedQuery.Trim();
var allowedStartPatterns = new[]
{
"SELECT"
};
bool isAllowed = allowedStartPatterns.Any(pattern => trimmedQuery.StartsWith(pattern, StringComparison.OrdinalIgnoreCase));
if (!isAllowed)
{
throw new InvalidOperationException("Only SELECT statements are allowed for security reasons.");
throw new InvalidOperationException("Multiple SQL statements are not allowed. Use only a single statement.");
}
}
@@ -53,7 +53,8 @@ public class DatabaseQueryCommandTests : SubscriptionCommandUnitTestsBase<Databa
[Fact]
public void Metadata_IsConfiguredCorrectly()
{
Assert.False(Command.Metadata.Destructive);
Assert.True(Command.Metadata.ReadOnly);
Assert.True(Command.Metadata.Destructive);
Assert.False(Command.Metadata.ReadOnly);
Assert.False(Command.Metadata.Idempotent);
}
}
@@ -33,10 +33,12 @@ public class MySqlServiceExecutionTests
[Theory]
[InlineData("SELECT * FROM users; DROP TABLE users")]
[InlineData("DROP TABLE users")]
public void ValidateQuerySafety_WithDangerousQuery_ShouldThrowInvalidOperationException(string query)
[InlineData("SELECT * FROM users; SELECT 1")]
public void ValidateQuerySafety_WithStackedStatements_ShouldThrowInvalidOperationException(string query)
{
var exception = Assert.Throws<InvalidOperationException>(() => MySqlService.ValidateQuerySafety(query));
Assert.Contains("Multiple SQL statements are not allowed", exception.Message);
}
[Fact]
@@ -25,42 +25,19 @@ public class MySqlServiceQueryValidationTests
[InlineData("SELECT preset FROM config")]
[InlineData("SELECT * FROM committees")]
[InlineData("SELECT VARCHAR(col) FROM t")]
public void ValidateQuerySafety_WithSafeQueries_ShouldNotThrow(string query)
{
// Act & Assert - Should not throw any exception
MySqlService.ValidateQuerySafety(query);
}
[Theory]
[InlineData("DROP TABLE users")]
[InlineData("DELETE FROM users")]
[InlineData("INSERT INTO users")]
[InlineData("UPDATE users SET")]
[InlineData("CREATE TABLE test")]
[InlineData("GRANT ALL PRIVILEGES")]
[InlineData("LOAD DATA INFILE")]
[InlineData("SELECT * INTO OUTFILE")]
public void ValidateQuerySafety_WithDangerousQueries_ShouldThrowInvalidOperationException(string query)
{
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => MySqlService.ValidateQuerySafety(query));
Assert.True(
exception.Message.Contains("dangerous keyword") ||
exception.Message.Contains("dangerous patterns"),
$"Expected error message to contain either 'dangerous keyword' or 'dangerous patterns', but got: {exception.Message}");
}
[Theory]
[InlineData("SELECT HEX('abc') FROM users")]
[InlineData("SELECT 1 UNION SELECT user FROM mysql.user")]
[InlineData("SHOW DATABASES")]
[InlineData("DESCRIBE users")]
[InlineData("EXPLAIN SELECT * FROM users")]
public void ValidateQuerySafety_WithDisallowedStatements_ShouldThrowInvalidOperationException(string query)
[InlineData("INSERT INTO users (id) VALUES (1)")]
[InlineData("UPDATE users SET name = 'x' WHERE id = 1")]
[InlineData("DELETE FROM users WHERE id = 1")]
[InlineData("DROP TABLE users")]
public void ValidateQuerySafety_WithAcceptedQueries_ShouldNotThrow(string query)
{
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => MySqlService.ValidateQuerySafety(query));
Assert.Contains("Only SELECT statements are allowed", exception.Message);
// Act & Assert - Should not throw any exception
MySqlService.ValidateQuerySafety(query);
}
[Theory]
@@ -103,22 +80,7 @@ public class MySqlServiceQueryValidationTests
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => MySqlService.ValidateQuerySafety(query));
Assert.Contains("Multiple SQL statements are not allowed. Use only a single SELECT statement.", exception.Message);
}
[Theory]
[InlineData("SELECT HEX('abc') FROM users")]
[InlineData("SELECT UNHEX('616263') FROM users")]
[InlineData("SELECT CONV('a',16,2) FROM users")]
public void ValidateQuerySafety_WithObfuscationFunctions_ShouldThrowInvalidOperationException(string query)
{
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => MySqlService.ValidateQuerySafety(query));
Assert.True(
exception.Message.Contains("Character conversion and obfuscation functions") ||
exception.Message.Contains("dangerous keyword"),
$"Expected obfuscation or keyword validation error, but got: {exception.Message}");
Assert.Contains("Multiple SQL statements are not allowed. Use only a single statement.", exception.Message);
}
[Theory]
@@ -133,16 +95,4 @@ public class MySqlServiceQueryValidationTests
Assert.Contains("SQL comments are not allowed for security reasons", exception.Message);
}
[Theory]
[InlineData("SELECT 1 UNION SELECT user FROM mysql.user")]
[InlineData("SELECT 1 INTERSECT SELECT 2")]
[InlineData("SELECT 1 EXCEPT SELECT 2")]
public void ValidateQuerySafety_WithSetOperations_ShouldThrowInvalidOperationException(string query)
{
// Act & Assert
var exception = Assert.Throws<InvalidOperationException>(() => MySqlService.ValidateQuerySafety(query));
Assert.Contains("dangerous keyword", exception.Message);
}
}
@@ -16,10 +16,10 @@ namespace Azure.Mcp.Tools.Postgres.Commands.Database;
Title = "Query PostgreSQL Database",
Description = "Executes a SQL query on an Azure Database for PostgreSQL server to search for specific terms, retrieve records, or perform SELECT operations.",
OperationPlane = ToolOperationPlane.Data,
Destructive = false,
Idempotent = true,
Destructive = true,
Idempotent = false,
OpenWorld = false,
ReadOnly = true,
ReadOnly = false,
Secret = false,
LocalRequired = false)]
public sealed class DatabaseQueryCommand(IPostgresService postgresService, ILogger<DatabaseQueryCommand> logger)
@@ -32,8 +32,8 @@ public sealed class DatabaseQueryCommand(IPostgresService postgresService, ILogg
{
try
{
// Validate the query early to avoid sending unsafe SQL to the server.
SqlQueryValidator.EnsureReadOnlySelect(options.Query);
// Validate the query early to reject malformed or stacked statements.
SqlQueryValidator.ValidateQuery(options.Query);
List<string> queryResult = await _postgresService.ExecuteQueryAsync(
options.AuthType,
options.User,
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Net;
using System.Text.RegularExpressions;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Helpers;
@@ -9,110 +8,20 @@ using Microsoft.Mcp.Core.Helpers;
namespace Azure.Mcp.Tools.Postgres.Validation;
/// <summary>
/// Lightweight validator to reduce risk of executing unsafe SQL statements entered via the tool.
/// Implements a conservative ALLOW list: only a single read-only SELECT statement with common, non-destructive
/// clauses is permitted. No subqueries, CTEs, UNION/INTERSECT/EXCEPT, DDL/DML, or procedural/privileged commands.
/// Identifiers (table / column / alias) are allowed if they don't collide with an explicitly disallowed keyword.
/// Dangerous PostgreSQL functions and system catalogs are explicitly blocked.
/// This is intentionally strict to minimize risk; relax only with strong justification.
/// Lightweight structural validator for SQL statements entered via the tool.
/// It does not restrict which SQL verbs may be executed; the command is marked destructive and the
/// caller's database permissions are the authority on what is allowed. Validation is limited to
/// rejecting empty or oversized input, SQL comments, and multiple / stacked statements.
/// </summary>
internal static class SqlQueryValidator
{
private const int MaxQueryLength = 5000; // Arbitrary safety cap to avoid extremely large inputs.
/// <summary>
/// SQL set-operation keywords that must be blocked to prevent data-exfiltration attacks
/// (e.g. SELECT 1 UNION SELECT usename FROM pg_shadow).
/// </summary>
private static readonly HashSet<string> DangerousKeywords = new(StringComparer.OrdinalIgnoreCase)
{
"UNION",
"INTERSECT",
"EXCEPT",
};
/// <summary>
/// Dangerous PostgreSQL functions and system catalogs that must be blocked even in SELECT queries.
/// These can read arbitrary files, list directories, access credentials, or enable lateral movement.
/// </summary>
private static readonly HashSet<string> DangerousIdentifiers = new(StringComparer.OrdinalIgnoreCase)
{
// File system read functions
"pg_read_file",
"pg_read_binary_file",
// Directory listing functions
"pg_ls_dir",
"pg_ls_logdir",
"pg_ls_waldir",
"pg_ls_tmpdir",
"pg_ls_archive_statusdir",
// File write / program execution
"pg_file_write",
"pg_execute_server_program",
// Large object functions (can read/write files via OIDs)
"lo_import",
"lo_export",
"lo_get",
"lo_put",
"lo_from_bytea",
// Credential / auth system catalogs
"pg_shadow",
"pg_authid",
"pg_user",
"pg_roles",
// External database access (lateral movement)
"dblink",
"dblink_connect",
"dblink_exec",
"dblink_send_query",
// Copy-based exfiltration
"pg_copy_to",
"pg_copy_from",
// Extension management
"pg_create_extension",
// Advisory lock abuse
"pg_advisory_lock",
"pg_advisory_unlock",
// File metadata
"pg_stat_file",
// Session termination (DoS)
"pg_terminate_backend",
"pg_cancel_backend",
// Server configuration manipulation
"pg_reload_conf",
"set_config",
"current_setting",
// Denial-of-service
"pg_sleep",
"generate_series",
// Cross-session information leak
"pg_stat_activity",
// TLS connection metadata exposure
"pg_stat_ssl",
// Foreign data wrapper credential exposure
"pg_user_mappings",
};
/// <summary>
/// Ensures the provided query is a single, read-only SELECT statement (no comments, no stacked statements).
/// Ensures the provided query is a single statement without comments and within the size limit.
/// Throws <see cref="CommandValidationException"/> when validation fails so callers receive a 400 response.
/// </summary>
public static void EnsureReadOnlySelect(string? query)
public static void ValidateQuery(string? query)
{
if (string.IsNullOrWhiteSpace(query))
{
@@ -129,12 +38,6 @@ internal static class SqlQueryValidator
// Allow an optional trailing semicolon; remove for further checks.
var core = trimmed.EndsWith(';') ? trimmed[..^1] : trimmed;
// Must start with SELECT (ignoring leading whitespace already trimmed)
if (!core.StartsWith("select", StringComparison.OrdinalIgnoreCase))
{
throw new CommandValidationException("Only single read-only SELECT statements are allowed.");
}
// Strip single-quoted string literals before checking for comment markers to avoid
// false positives (e.g., 'foo--bar' or '/* not a comment */' are not comments).
// Standard literals use only doubled quotes ('') as escape; backslash is literal
@@ -155,44 +58,5 @@ internal static class SqlQueryValidator
{
throw new CommandValidationException("Multiple or stacked SQL statements are not allowed.");
}
var lower = core.ToLowerInvariant();
// Naive detection of tautology patterns still applied before token-level allow list.
if (lower.Contains(" or 1=1") || lower.Contains(" or '1'='1"))
{
throw new CommandValidationException("Suspicious boolean tautology pattern detected.");
}
// Tokenize: capture word tokens (letters / underscore). Numerics & punctuation ignored.
var matches = Regex.Matches(withoutStrings, "[A-Za-z_]+", RegexOptions.Compiled, RegexHelper.DefaultRegexTimeout);
if (matches.Count == 0)
{
throw new CommandValidationException("Query must contain a SELECT statement.");
}
// First significant token must be SELECT.
if (!matches[0].Value.Equals("select", StringComparison.OrdinalIgnoreCase))
{
throw new CommandValidationException("Only single read-only SELECT statements are allowed.");
}
// Check all tokens against blocklist of dangerous functions and system catalogs.
foreach (Match match in matches)
{
if (DangerousIdentifiers.Contains(match.Value))
{
throw new CommandValidationException(
$"Function or identifier '{match.Value}' is not allowed.",
HttpStatusCode.BadRequest);
}
if (DangerousKeywords.Contains(match.Value))
{
throw new CommandValidationException(
$"Query contains dangerous keyword '{match.Value.ToUpperInvariant()}' which is not allowed for security reasons.",
HttpStatusCode.BadRequest);
}
}
}
}
@@ -86,47 +86,10 @@ public class DatabaseQueryCommandTests : CommandUnitTestsBase<DatabaseQueryComma
}
[Theory]
[InlineData("DELETE FROM users;")]
[InlineData("SELECT * FROM users; DROP TABLE users;")]
[InlineData("SELECT * FROM users -- comment")] // inline comment
[InlineData("SELECT * FROM users /* block comment */")] // block comment
[InlineData("SELECT * FROM users; SELECT * FROM other;")] // stacked
[InlineData("UPDATE accounts SET balance=0;")]
[InlineData("SELECT pg_read_file('/etc/passwd')")] // file read
[InlineData("SELECT pg_ls_archive_statusdir()")] // archive status directory listing
[InlineData("SELECT pg_execute_server_program('id')")] // server program execution
[InlineData("SELECT lo_export(12345, '/tmp/out')")] // large object export
[InlineData("SELECT lo_put(12345, 0, 'data')")] // large object write
[InlineData("SELECT lo_from_bytea(0, 'data')")] // large object from bytea
[InlineData("SELECT dblink_exec('host=evil.com', 'DROP TABLE x')")] // remote exec
[InlineData("SELECT dblink_send_query('conn', 'SELECT 1')")] // remote async query
[InlineData("SELECT pg_copy_to('users', '/tmp/dump')")] // copy-based exfiltration
[InlineData("SELECT pg_copy_from('users', '/tmp/payload')")] // copy-based injection
[InlineData("SELECT pg_create_extension('evil_ext')")] // extension install
[InlineData("SELECT pg_advisory_lock(1)")] // advisory lock abuse
[InlineData("SELECT pg_advisory_unlock(1)")] // advisory unlock
[InlineData("SELECT pg_read_binary_file('/etc/hostname')")] // binary file read
[InlineData("SELECT pg_ls_dir('/etc')")] // directory listing
[InlineData("SELECT pg_ls_logdir()")] // log directory listing
[InlineData("SELECT pg_ls_waldir()")] // WAL directory listing
[InlineData("SELECT pg_ls_tmpdir()")] // tmp directory listing
[InlineData("SELECT usename, passwd FROM pg_shadow")] // credential access
[InlineData("SELECT rolname, rolsuper FROM pg_authid")] // auth access
[InlineData("SELECT lo_import('/etc/passwd')")] // large object import
[InlineData("SELECT lo_get(12345)")] // large object read
[InlineData("SELECT dblink('host=evil.com')")] // external connection
[InlineData("SELECT dblink_connect('host=evil.com')")] // external connection
[InlineData("SELECT pg_file_write('/tmp/evil', 'data', false)")] // file write
[InlineData("SELECT encode(pg_read_binary_file('/etc/hostname'), 'hex')")] // encoded file read
[InlineData("SELECT pg_stat_file('/etc/passwd')")] // file metadata
[InlineData("SELECT pg_terminate_backend(1234)")] // session kill DoS
[InlineData("SELECT pg_cancel_backend(1234)")] // session cancel DoS
[InlineData("SELECT pg_reload_conf()")] // config reload
[InlineData("SELECT set_config('log_statement', 'all', false)")] // runtime setting change
[InlineData("SELECT current_setting('config_file')")] // setting leak
[InlineData("SELECT pg_sleep(3600)")] // denial-of-service
[InlineData("SELECT * FROM pg_stat_activity")] // cross-session info leak
[InlineData("SELECT * FROM pg_user_mappings")] // FDW credential exposure
public async Task ExecuteAsync_InvalidQuery_ValidationError(string badQuery)
{
var response = await ExecuteCommandAsync(
@@ -142,6 +105,28 @@ public class DatabaseQueryCommandTests : CommandUnitTestsBase<DatabaseQueryComma
await Service.DidNotReceive().ExecuteQueryAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
}
[Theory]
[InlineData("DELETE FROM users;")]
[InlineData("UPDATE accounts SET balance=0;")]
[InlineData("SELECT pg_read_file('/etc/passwd')")]
[InlineData("SELECT pg_sleep(3600)")]
public async Task ExecuteAsync_NonSelectQuery_IsForwardedToService(string query)
{
Service.ExecuteQueryAsync(AuthTypes.MicrosoftEntra, "user1", null, "server1", "db123", query, Arg.Any<CancellationToken>())
.Returns([]);
var response = await ExecuteCommandAsync(
$"--{PostgresOptionDefinitions.AuthTypeText}", AuthTypes.MicrosoftEntra,
"--user", "user1",
"--server", "server1",
"--database", "db123",
"--query", query);
Assert.NotNull(response);
Assert.Equal(HttpStatusCode.OK, response.Status);
await Service.Received(1).ExecuteQueryAsync(AuthTypes.MicrosoftEntra, "user1", null, "server1", "db123", query, Arg.Any<CancellationToken>());
}
[Fact]
public async Task ExecuteAsync_LongQuery_ValidationError()
{
@@ -410,7 +410,7 @@ public class PostgresCommandTests(ITestOutputHelper output, LiveServerFixture li
[LiveTestOnly]
[Fact]
public async Task Should_RejectNonSelectQuery_WithValidationError()
public async Task Should_RejectStackedQuery_WithValidationError()
{
JsonElement error = await this.CallToolAsyncWithErrorExpected("postgres_database_query",
new()
@@ -419,14 +419,14 @@ public class PostgresCommandTests(ITestOutputHelper output, LiveServerFixture li
{ "database", TestDatabaseName },
{ PostgresOptionDefinitions.AuthTypeText, AuthTypes.MicrosoftEntra },
{ "user", AdminUsername },
{ "query", "DELETE FROM employees WHERE id = 1;" }
{ "query", "SELECT * FROM employees; DELETE FROM employees WHERE id = 1;" }
});
int errorStatus = error.GetProperty("status").GetInt32();
Assert.Equal(400, errorStatus);
string errorMessage = error.GetProperty("message").GetString()!;
Assert.Equal("Only single read-only SELECT statements are allowed.", errorMessage);
Assert.Equal("Multiple or stacked SQL statements are not allowed.", errorMessage);
}
[LiveTestOnly]
@@ -24,9 +24,17 @@ public class SqlQueryValidatorTests
[InlineData("SELECT preset FROM config")]
[InlineData("SELECT * FROM intersections")]
[InlineData("SELECT * FROM exceptions")]
public void EnsureReadOnlySelect_WithSafeQueries_ShouldNotThrow(string query)
[InlineData("SELECT 'drop table' AS msg FROM users")]
[InlineData("SELECT * FROM users WHERE note = 'pg_sleep is bad'")]
[InlineData("SELECT 1 UNION SELECT 2")]
[InlineData("SELECT * FROM users WHERE id = 1 or 1=1")]
[InlineData("INSERT INTO users VALUES (1)")]
[InlineData("UPDATE users SET name = 'x'")]
[InlineData("DELETE FROM users WHERE id = 1")]
[InlineData("DROP TABLE users")]
public void ValidateQuery_WithAcceptedQueries_ShouldNotThrow(string query)
{
SqlQueryValidator.EnsureReadOnlySelect(query);
SqlQueryValidator.ValidateQuery(query);
}
[Theory]
@@ -34,56 +42,18 @@ public class SqlQueryValidatorTests
[InlineData("SELECT 1 /* block comment */")]
[InlineData("SELECT 'foo\\' /* ' FROM pg_shadow --'")] // backslash does not escape quotes in standard strings
[InlineData("SELECT * FROM users WHERE name = 'it\\'s a test -- ok'")] // \' is not an escape in standard SQL
public void EnsureReadOnlySelect_WithComments_ShouldThrow(string query)
public void ValidateQuery_WithComments_ShouldThrow(string query)
{
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.EnsureReadOnlySelect(query));
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.ValidateQuery(query));
Assert.Contains("Comments are not allowed", exception.Message);
}
[Theory]
[InlineData("SELECT 1 UNION SELECT usename FROM users")]
[InlineData("SELECT 1 UNION ALL SELECT 2")]
[InlineData("SELECT 1 INTERSECT SELECT 2")]
[InlineData("SELECT 1 EXCEPT SELECT 2")]
[InlineData("SELECT 1 union select 2")]
public void EnsureReadOnlySelect_WithSetOperations_ShouldThrow(string query)
{
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.EnsureReadOnlySelect(query));
Assert.Contains("dangerous keyword", exception.Message);
}
[Theory]
[InlineData("SELECT pg_read_file('/etc/passwd')")]
[InlineData("SELECT * FROM pg_shadow")]
[InlineData("SELECT usename, passwd FROM pg_user")]
[InlineData("SELECT rolname, rolsuper, rolcanlogin FROM pg_roles")]
[InlineData("SELECT dblink('host=evil.com', 'SELECT 1')")]
[InlineData("SELECT pg_sleep(999)")]
[InlineData("SELECT * FROM pg_stat_ssl")]
[InlineData("SELECT * FROM generate_series(1,1000000000)")]
public void EnsureReadOnlySelect_WithDangerousIdentifiers_ShouldThrow(string query)
{
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.EnsureReadOnlySelect(query));
Assert.Contains("is not allowed", exception.Message);
}
[Theory]
[InlineData("DROP TABLE users")]
[InlineData("DELETE FROM users")]
[InlineData("INSERT INTO users VALUES (1)")]
[InlineData("UPDATE users SET name = 'x'")]
public void EnsureReadOnlySelect_WithNonSelectStatements_ShouldThrow(string query)
{
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.EnsureReadOnlySelect(query));
Assert.Contains("Only single read-only SELECT statements are allowed", exception.Message);
}
[Theory]
[InlineData("SELECT * FROM users; DROP TABLE users")]
[InlineData("SELECT * FROM users; SELECT * FROM products")]
public void EnsureReadOnlySelect_WithMultipleStatements_ShouldThrow(string query)
public void ValidateQuery_WithMultipleStatements_ShouldThrow(string query)
{
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.EnsureReadOnlySelect(query));
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.ValidateQuery(query));
Assert.Contains("Multiple or stacked SQL statements are not allowed", exception.Message);
}
@@ -91,27 +61,18 @@ public class SqlQueryValidatorTests
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void EnsureReadOnlySelect_WithEmptyOrNullQuery_ShouldThrow(string? query)
public void ValidateQuery_WithEmptyOrNullQuery_ShouldThrow(string? query)
{
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.EnsureReadOnlySelect(query));
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.ValidateQuery(query));
Assert.Contains("Query cannot be empty", exception.Message);
}
[Fact]
public void EnsureReadOnlySelect_WithLongQuery_ShouldThrow()
public void ValidateQuery_WithLongQuery_ShouldThrow()
{
var longQuery = "SELECT * FROM users WHERE " + new string('X', 5001);
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.EnsureReadOnlySelect(longQuery));
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.ValidateQuery(longQuery));
Assert.Contains("Query length exceeds limit", exception.Message);
}
[Theory]
[InlineData("SELECT * FROM users WHERE id = 1 or 1=1")]
[InlineData("SELECT * FROM users WHERE id = 1 or '1'='1")]
public void EnsureReadOnlySelect_WithTautologyPatterns_ShouldThrow(string query)
{
var exception = Assert.Throws<CommandValidationException>(() => SqlQueryValidator.EnsureReadOnlySelect(query));
Assert.Contains("tautology", exception.Message);
}
}