-
Notifications
You must be signed in to change notification settings - Fork 117
Implement READONLY mode checks and enhance SQL query validation in Tools #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||
---|---|---|---|---|---|---|---|---|
|
@@ -2,22 +2,197 @@ | |||||||
// Licensed under the MIT license. | ||||||||
|
||||||||
using System.ComponentModel; | ||||||||
using System.Text.RegularExpressions; | ||||||||
using Microsoft.Data.SqlClient; | ||||||||
using Microsoft.Extensions.Logging; | ||||||||
using ModelContextProtocol.Server; | ||||||||
|
||||||||
namespace Mssql.McpServer; | ||||||||
public partial class Tools | ||||||||
{ | ||||||||
// List of dangerous SQL keywords that should not be allowed | ||||||||
private static readonly string[] DangerousKeywords = | ||||||||
[ | ||||||||
"DELETE", "DROP", "UPDATE", "INSERT", "ALTER", "CREATE", | ||||||||
"TRUNCATE", "EXEC", "EXECUTE", "MERGE", "REPLACE", | ||||||||
"GRANT", "REVOKE", "COMMIT", "ROLLBACK", "TRANSACTION", | ||||||||
"BEGIN", "DECLARE", "SET", "USE", "BACKUP", | ||||||||
"RESTORE", "KILL", "SHUTDOWN", "WAITFOR", "OPENROWSET", | ||||||||
"OPENDATASOURCE", "OPENQUERY", "OPENXML", "BULK" | ||||||||
]; | ||||||||
|
||||||||
// Regex patterns to detect common SQL injection techniques | ||||||||
private static readonly Regex[] DangerousPatterns = | ||||||||
[ | ||||||||
// Semicolon followed by dangerous keywords | ||||||||
new(@";\s*(DELETE|DROP|UPDATE|INSERT|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|MERGE|REPLACE|GRANT|REVOKE)", RegexOptions.IgnoreCase), | ||||||||
|
||||||||
// UNION injection attempts with dangerous keywords | ||||||||
new(@"UNION\s+(?:ALL\s+)?SELECT.*?(DELETE|DROP|UPDATE|INSERT|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE)", RegexOptions.IgnoreCase), | ||||||||
|
||||||||
// Comment-based injection attempts | ||||||||
new(@"--.*?(DELETE|DROP|UPDATE|INSERT|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE)", RegexOptions.IgnoreCase), | ||||||||
new(@"/\*.*?(DELETE|DROP|UPDATE|INSERT|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE).*?\*/", RegexOptions.IgnoreCase), | ||||||||
|
||||||||
// Stored procedure execution patterns | ||||||||
new(@"EXEC\s*\(", RegexOptions.IgnoreCase), | ||||||||
new(@"EXECUTE\s*\(", RegexOptions.IgnoreCase), | ||||||||
new(@"sp_", RegexOptions.IgnoreCase), | ||||||||
new(@"xp_", RegexOptions.IgnoreCase), | ||||||||
|
||||||||
// Bulk operations | ||||||||
new(@"BULK\s+INSERT", RegexOptions.IgnoreCase), | ||||||||
new(@"OPENROWSET", RegexOptions.IgnoreCase), | ||||||||
new(@"OPENDATASOURCE", RegexOptions.IgnoreCase), | ||||||||
|
||||||||
// System functions that could be dangerous | ||||||||
new(@"@@", RegexOptions.None), | ||||||||
new(@"SYSTEM_USER", RegexOptions.IgnoreCase), | ||||||||
new(@"USER_NAME", RegexOptions.IgnoreCase), | ||||||||
new(@"DB_NAME", RegexOptions.IgnoreCase), | ||||||||
new(@"HOST_NAME", RegexOptions.IgnoreCase), | ||||||||
|
||||||||
// Time delay attacks | ||||||||
new(@"WAITFOR\s+DELAY", RegexOptions.IgnoreCase), | ||||||||
new(@"WAITFOR\s+TIME", RegexOptions.IgnoreCase), | ||||||||
|
||||||||
// Multiple statements (semicolon not at end) | ||||||||
new(@";\s*\w", RegexOptions.None), | ||||||||
|
||||||||
// String concatenation that might hide malicious code | ||||||||
new(@"\+\s*CHAR\s*\(", RegexOptions.IgnoreCase), | ||||||||
new(@"\+\s*NCHAR\s*\(", RegexOptions.IgnoreCase), | ||||||||
new(@"\+\s*ASCII\s*\(", RegexOptions.IgnoreCase) | ||||||||
]; | ||||||||
|
||||||||
/// <summary> | ||||||||
/// Validates the SQL query for security issues | ||||||||
/// </summary> | ||||||||
/// <param name="query">The SQL query to validate</param> | ||||||||
/// <returns>Validation result with success flag and error message if invalid</returns> | ||||||||
private (bool IsValid, string? Error) ValidateQuery(string query) | ||||||||
{ | ||||||||
if (string.IsNullOrWhiteSpace(query)) | ||||||||
{ | ||||||||
return (false, "Query must be a non-empty string"); | ||||||||
} | ||||||||
|
||||||||
// Remove comments and normalize whitespace for analysis | ||||||||
var cleanQuery = Regex.Replace(query, @"--.*$", "", RegexOptions.Multiline) // Remove line comments | ||||||||
.Replace("/*", "").Replace("*/", "") // Remove block comments (simple approach) | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The block comment removal using simple string replacement is flawed. It will remove '/' and '/' independently, which could break valid queries. For example, a query with '/* comment 1 / SELECT * FROM / comment 2 /' would become ' SELECT * FROM ' but remove both opening and closing markers incorrectly. Use proper regex pattern like @'/*.?*/' with RegexOptions.Singleline.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||
.Trim(); | ||||||||
|
||||||||
cleanQuery = Regex.Replace(cleanQuery, @"\s+", " "); // Normalize whitespace | ||||||||
|
||||||||
if (string.IsNullOrWhiteSpace(cleanQuery)) | ||||||||
{ | ||||||||
return (false, "Query cannot be empty after removing comments"); | ||||||||
} | ||||||||
|
||||||||
var upperQuery = cleanQuery.ToUpperInvariant(); | ||||||||
|
||||||||
// Must start with SELECT | ||||||||
if (!upperQuery.StartsWith("SELECT")) | ||||||||
{ | ||||||||
return (false, "Query must start with SELECT for security reasons"); | ||||||||
} | ||||||||
|
||||||||
// Check for dangerous keywords in the cleaned query using word boundaries | ||||||||
foreach (var keyword in DangerousKeywords) | ||||||||
{ | ||||||||
// Use word boundary regex to match only complete keywords, not parts of words | ||||||||
var keywordRegex = new Regex($@"(^|\s|[^A-Za-z0-9_]){keyword}($|\s|[^A-Za-z0-9_])", RegexOptions.IgnoreCase); | ||||||||
if (keywordRegex.IsMatch(upperQuery)) | ||||||||
{ | ||||||||
return (false, $"Dangerous keyword '{keyword}' detected in query. Only SELECT operations are allowed."); | ||||||||
} | ||||||||
} | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Creating a new Regex object for each keyword in each validation call is inefficient. Consider pre-compiling all keyword regexes as static readonly Regex[] or use a single regex pattern with alternation like @'\b(DELETE|DROP|UPDATE|...)\b'. Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||
|
||||||||
// Check for dangerous patterns using regex | ||||||||
foreach (var pattern in DangerousPatterns) | ||||||||
{ | ||||||||
if (pattern.IsMatch(query)) | ||||||||
{ | ||||||||
return (false, "Potentially malicious SQL pattern detected. Only simple SELECT queries are allowed."); | ||||||||
} | ||||||||
} | ||||||||
|
||||||||
// Additional validation: Check for multiple statements | ||||||||
var statements = cleanQuery.Split(';', StringSplitOptions.RemoveEmptyEntries); | ||||||||
if (statements.Length > 1) | ||||||||
{ | ||||||||
return (false, "Multiple SQL statements are not allowed. Use only a single SELECT statement."); | ||||||||
} | ||||||||
|
||||||||
// Check for suspicious string patterns that might indicate obfuscation | ||||||||
if (query.Contains("CHAR(") || query.Contains("NCHAR(") || query.Contains("ASCII(")) | ||||||||
{ | ||||||||
return (false, "Character conversion functions are not allowed as they may be used for obfuscation."); | ||||||||
} | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This validation is too restrictive and could prevent legitimate queries. Functions like CHAR(), NCHAR(), and ASCII() have valid use cases in SELECT queries for data formatting and analysis. Consider allowing these functions when used in a safe context rather than blanket prohibition.
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||
|
||||||||
// Limit query length to prevent potential DoS | ||||||||
if (query.Length > 10000) | ||||||||
{ | ||||||||
return (false, "Query is too long. Maximum allowed length is 10,000 characters."); | ||||||||
} | ||||||||
|
||||||||
return (true, null); | ||||||||
} | ||||||||
|
||||||||
/// <summary> | ||||||||
/// Sanitizes the query result to prevent any potential security issues | ||||||||
/// </summary> | ||||||||
/// <param name="data">The query result data</param> | ||||||||
/// <returns>Sanitized data</returns> | ||||||||
private List<Dictionary<string, object?>> SanitizeResult(List<Dictionary<string, object?>> data) | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The SanitizeResult method processes all data even when no sanitization is needed. Consider adding an early check to see if any column names contain suspicious characters before processing the entire dataset. Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||||
{ | ||||||||
// Limit the number of returned records to prevent memory issues | ||||||||
const int maxRecords = 10000; | ||||||||
if (data.Count > maxRecords) | ||||||||
{ | ||||||||
_logger.LogWarning("Query returned {Count} records, limiting to {MaxRecords}", data.Count, maxRecords); | ||||||||
data = data.Take(maxRecords).ToList(); | ||||||||
} | ||||||||
|
||||||||
return data.Select(record => | ||||||||
{ | ||||||||
var sanitized = new Dictionary<string, object?>(); | ||||||||
foreach (var (key, value) in record) | ||||||||
{ | ||||||||
// Sanitize column names (remove any suspicious characters) | ||||||||
var sanitizedKey = Regex.Replace(key, @"[^\w\s\-_.]", ""); | ||||||||
if (sanitizedKey != key) | ||||||||
{ | ||||||||
_logger.LogWarning("Column name sanitized: {Original} -> {Sanitized}", key, sanitizedKey); | ||||||||
} | ||||||||
sanitized[sanitizedKey] = value; | ||||||||
} | ||||||||
return sanitized; | ||||||||
}).ToList(); | ||||||||
} | ||||||||
|
||||||||
[McpServerTool( | ||||||||
Title = "Read Data", | ||||||||
ReadOnly = true, | ||||||||
Idempotent = true, | ||||||||
Destructive = false), | ||||||||
Description("Executes SQL queries against SQL Database to read data")] | ||||||||
Description("Executes a SELECT query on an MSSQL Database table. The query must start with SELECT and cannot contain any destructive SQL operations for security reasons.")] | ||||||||
public async Task<DbOperationResult> ReadData( | ||||||||
[Description("SQL query to execute")] string sql) | ||||||||
[Description("SQL SELECT query to execute (must start with SELECT and cannot contain destructive operations). Example: SELECT * FROM movies WHERE genre = 'comedy'")] string sql) | ||||||||
{ | ||||||||
// Validate the query for security issues | ||||||||
var (isValid, error) = ValidateQuery(sql); | ||||||||
if (!isValid) | ||||||||
{ | ||||||||
_logger.LogWarning("Security validation failed for query: {QueryStart}...", sql.Length > 100 ? sql[..100] : sql); | ||||||||
return new DbOperationResult(success: false, error: $"Security validation failed: {error}"); | ||||||||
} | ||||||||
|
||||||||
// Log the query for audit purposes (in production, consider more secure logging) | ||||||||
_logger.LogInformation("Executing validated SELECT query: {QueryStart}{Truncated}", | ||||||||
sql.Length > 200 ? sql[..200] : sql, | ||||||||
sql.Length > 200 ? "..." : ""); | ||||||||
|
||||||||
var conn = await _connectionFactory.GetOpenConnectionAsync(); | ||||||||
try | ||||||||
{ | ||||||||
|
@@ -35,13 +210,25 @@ public async Task<DbOperationResult> ReadData( | |||||||
} | ||||||||
results.Add(row); | ||||||||
} | ||||||||
return new DbOperationResult(success: true, data: results); | ||||||||
|
||||||||
// Sanitize the result | ||||||||
var sanitizedResults = SanitizeResult(results); | ||||||||
|
||||||||
return new DbOperationResult( | ||||||||
success: true, | ||||||||
data: sanitizedResults); | ||||||||
} | ||||||||
} | ||||||||
catch (Exception ex) | ||||||||
{ | ||||||||
_logger.LogError(ex, "ReadData failed: {Message}", ex.Message); | ||||||||
return new DbOperationResult(success: false, error: ex.Message); | ||||||||
|
||||||||
// Don't expose internal error details to prevent information leakage | ||||||||
var safeErrorMessage = ex.Message.Contains("Invalid object name") | ||||||||
? ex.Message | ||||||||
: "Database query execution failed"; | ||||||||
|
||||||||
return new DbOperationResult(success: false, error: $"Failed to execute query: {safeErrorMessage}"); | ||||||||
} | ||||||||
} | ||||||||
} | ||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The keyword 'BEGIN' in the dangerous keywords list could prevent legitimate SELECT queries that use BEGIN...END blocks in subqueries or CTEs. Consider being more specific (e.g., 'BEGIN TRANSACTION') or handle this in the pattern validation instead.
Copilot uses AI. Check for mistakes.