mirror of
https://github.com/microsoft/mcp.git
synced 2026-11-04 12:01:31 +00:00
* 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>
147 lines
6.0 KiB
C#
147 lines
6.0 KiB
C#
// Copyright (c) Microsoft Corporation.
|
|
// Licensed under the MIT License.
|
|
|
|
using System.Diagnostics;
|
|
using System.Net;
|
|
using Azure.Mcp.Tools.Postgres.Commands;
|
|
using Azure.Mcp.Tools.Postgres.Commands.Database;
|
|
using Azure.Mcp.Tools.Postgres.Options;
|
|
using Azure.Mcp.Tools.Postgres.Services;
|
|
using Microsoft.Mcp.Core.TestUtilities;
|
|
using Microsoft.Mcp.Tests.Client;
|
|
using NSubstitute;
|
|
using Xunit;
|
|
|
|
namespace Azure.Mcp.Tools.Postgres.Tests.Database;
|
|
|
|
[DebuggerStepThrough]
|
|
public class DatabaseQueryCommandTests : CommandUnitTestsBase<DatabaseQueryCommand, IPostgresService>
|
|
{
|
|
[Fact]
|
|
public async Task ExecuteAsync_ReturnsQueryResults_WhenQueryIsValid()
|
|
{
|
|
var expectedResults = new List<string> { "result1", "result2" };
|
|
|
|
Service.ExecuteQueryAsync(AuthTypes.MicrosoftEntra, "user1", null, "server1", "db123", "SELECT * FROM test;", Arg.Any<CancellationToken>())
|
|
.Returns(expectedResults);
|
|
|
|
var response = await ExecuteCommandAsync(
|
|
$"--{PostgresOptionDefinitions.AuthTypeText}", AuthTypes.MicrosoftEntra,
|
|
"--user", "user1",
|
|
"--server", "server1",
|
|
"--database", "db123",
|
|
"--query", "SELECT * FROM test;");
|
|
|
|
var result = ValidateAndDeserializeResponse(response, PostgresJsonContext.Default.DatabaseQueryCommandResult);
|
|
Assert.Equal(expectedResults, result.QueryResult);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ExecuteAsync_ReturnsEmpty_WhenQueryFails()
|
|
{
|
|
Service.ExecuteQueryAsync(AuthTypes.MicrosoftEntra, "user1", null, "server1", "db123", "SELECT * FROM test;", Arg.Any<CancellationToken>())
|
|
.Returns([]);
|
|
|
|
var response = await ExecuteCommandAsync(
|
|
$"--{PostgresOptionDefinitions.AuthTypeText}", AuthTypes.MicrosoftEntra,
|
|
"--user", "user1",
|
|
"--server", "server1",
|
|
"--database", "db123",
|
|
"--query", "SELECT * FROM test;");
|
|
|
|
var result = ValidateAndDeserializeResponse(response, PostgresJsonContext.Default.DatabaseQueryCommandResult);
|
|
Assert.Empty(result.QueryResult);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("--user")]
|
|
[InlineData("--server")]
|
|
[InlineData("--database")]
|
|
[InlineData("--query")]
|
|
public async Task ExecuteAsync_ReturnsError_WhenParameterIsMissing(string missingParameter)
|
|
{
|
|
var response = await ExecuteCommandAsync(ArgBuilder.BuildArgs(missingParameter,
|
|
($"--{PostgresOptionDefinitions.AuthTypeText}", AuthTypes.MicrosoftEntra),
|
|
("--user", "user1"),
|
|
("--server", "server123"),
|
|
("--database", "db123"),
|
|
("--query", "SELECT * FROM test;")
|
|
));
|
|
|
|
Assert.NotNull(response);
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
|
|
Assert.Contains($"Missing Required options: {missingParameter}", response.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public void Command_DoesNotExposeArmScopingOptions()
|
|
{
|
|
var optionNames = CommandDefinition.Options.Select(o => o.Name.TrimStart('-')).ToList();
|
|
|
|
Assert.DoesNotContain("subscription", optionNames);
|
|
Assert.DoesNotContain("resource-group", optionNames);
|
|
Assert.Contains("user", optionNames);
|
|
Assert.Contains("server", optionNames);
|
|
Assert.Contains("database", optionNames);
|
|
}
|
|
|
|
[Theory]
|
|
[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
|
|
public async Task ExecuteAsync_InvalidQuery_ValidationError(string badQuery)
|
|
{
|
|
var response = await ExecuteCommandAsync(
|
|
$"--{PostgresOptionDefinitions.AuthTypeText}", AuthTypes.MicrosoftEntra,
|
|
"--user", "user1",
|
|
"--server", "server1",
|
|
"--database", "db123",
|
|
"--query", badQuery);
|
|
|
|
Assert.NotNull(response);
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.Status); // CommandValidationException => 400
|
|
// Service should never be called for invalid queries.
|
|
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()
|
|
{
|
|
var longSelect = "SELECT " + new string('a', 6000) + " FROM test"; // exceeds max length
|
|
var response = await ExecuteCommandAsync(
|
|
$"--{PostgresOptionDefinitions.AuthTypeText}", AuthTypes.MicrosoftEntra,
|
|
"--user", "user1",
|
|
"--server", "server1",
|
|
"--database", "db123",
|
|
"--query", longSelect);
|
|
|
|
Assert.NotNull(response);
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.Status);
|
|
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>());
|
|
}
|
|
}
|
|
|