sozsoft-platform/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlGuard.cs

159 lines
6.2 KiB
C#
Raw Normal View History

2026-09-17 09:01:14 +00:00
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Localization;
2026-10-17 18:51:10 +00:00
using Sozsoft.Platform.Data;
2026-09-17 09:01:14 +00:00
using Sozsoft.Platform.Enums;
using Sozsoft.Platform.Localization;
using Volo.Abp.Domain.Services;
namespace Sozsoft.Mcp.Services;
/// <summary>
/// Salt-okunur sorgu kurali. Hem serbest SELECT hem kayitli nesne cagrisi buradan gecer:
/// ifadenin gercekten okuma oldugu dogrulanir, tanimlayici ve parametre adlari kalibi gecer,
/// satir tavani sorguya yazilir.
/// </summary>
public interface IMcpSqlGuard
{
2026-10-10 22:03:36 +00:00
/// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse nedenini dondurur.</summary>
McpProblem? CheckReadOnlyStatement(string sql);
2026-09-17 09:01:14 +00:00
/// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary>
2026-10-10 22:03:36 +00:00
McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType);
2026-09-17 09:01:14 +00:00
/// <summary>
/// Satir tavanini sorgunun kendisine yazar: SQL Server'da <c>TOP (n)</c>, PostgreSQL'de
/// <c>LIMIT n</c>. Sorgu kendi sinirini zaten tasiyorsa dokunulmaz.
/// </summary>
string ApplyRowLimit(string sql, int maxRows, DataSourceTypeEnum dataSourceType);
/// <summary>
/// Istemciden gelen parametre adlarini dogrular ve bastaki @ isaretini duserek Dapper'in
/// bekledigi bicime cevirir.
/// </summary>
2026-10-10 22:03:36 +00:00
McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters);
2026-09-17 09:01:14 +00:00
}
public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlGuard
{
private static readonly Regex SafeParameterNameRegex = new(
@"^@?[A-Za-z_][A-Za-z0-9_]{0,127}$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SqlCommentRegex = new(
@"--[^\r\n]*|/\*[\s\S]*?\*/",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
private static readonly Regex SqlStringLiteralRegex = new(
@"'(?:[^']|'')*'",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Okuma sozde kalmasi gereken ifadelerde yasakli anahtar kelimeler. Kontrol yorumlar ve
/// metin sabitleri cikarildiktan sonra yapilir; aksi halde bir yorum blogu ya da tirnak
/// icindeki metin yasakli bir kelimeyi gizleyebilir.
/// </summary>
private static readonly Regex ForbiddenStatementRegex = new(
@"\b(INSERT|UPDATE|DELETE|MERGE|UPSERT|CREATE|ALTER|DROP|TRUNCATE|GRANT|REVOKE|DENY|BACKUP|RESTORE|SHUTDOWN|RECONFIGURE|EXEC|EXECUTE|WAITFOR|OPENROWSET|OPENDATASOURCE|OPENQUERY|BULK|INTO)\b",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex SelectPrefixRegex = new(
@"^\s*SELECT\s+(?:(?:DISTINCT|ALL)\s+)?",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex ExistingTopRegex = new(
@"^\s*SELECT\s+(?:(?:DISTINCT|ALL)\s+)?TOP\s*[(\s]",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex ExistingLimitRegex = new(
@"\bLIMIT\s+\d+",
RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
2026-10-10 22:03:36 +00:00
public McpProblem? CheckReadOnlyStatement(string sql)
2026-09-17 09:01:14 +00:00
{
if (string.IsNullOrWhiteSpace(sql))
{
2026-10-10 22:03:36 +00:00
return McpProblem.From(localizer, McpErrorCodes.EmptyQuery);
2026-09-17 09:01:14 +00:00
}
var stripped = SqlStringLiteralRegex.Replace(SqlCommentRegex.Replace(sql, " "), "''");
if (stripped.Contains(';', StringComparison.Ordinal))
{
2026-10-10 22:03:36 +00:00
return McpProblem.From(localizer, McpErrorCodes.SingleStatementOnly);
2026-09-17 09:01:14 +00:00
}
var trimmed = stripped.TrimStart();
var startsWithRead = trimmed.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("WITH", StringComparison.OrdinalIgnoreCase);
if (!startsWithRead)
{
2026-10-10 22:03:36 +00:00
return McpProblem.From(localizer, McpErrorCodes.ReadOnlyStatementRequired);
2026-09-17 09:01:14 +00:00
}
var forbidden = ForbiddenStatementRegex.Match(stripped);
2026-10-10 22:03:36 +00:00
return forbidden.Success
? McpProblem.From(localizer, McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant())
: null;
2026-09-17 09:01:14 +00:00
}
2026-10-10 22:03:36 +00:00
public McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
2026-09-17 09:01:14 +00:00
{
2026-10-10 22:03:36 +00:00
foreach (var name in (string?[])[schemaName, objectName])
{
2026-10-17 18:51:10 +00:00
if (!SqlIdentifier.IsSafe(name))
2026-10-10 22:03:36 +00:00
{
return McpProblem.From(localizer, McpErrorCodes.InvalidIdentifier, name ?? string.Empty);
}
}
2026-09-17 09:01:14 +00:00
2026-10-17 18:51:10 +00:00
return SqlIdentifier.QuoteQualified(schemaName, objectName, dataSourceType);
2026-09-17 09:01:14 +00:00
}
public string ApplyRowLimit(string sql, int maxRows, DataSourceTypeEnum dataSourceType)
{
if (dataSourceType == DataSourceTypeEnum.Postgresql)
{
return ExistingLimitRegex.IsMatch(sql) ? sql : $"{sql} LIMIT {maxRows}";
}
if (ExistingTopRegex.IsMatch(sql))
{
return sql;
}
var match = SelectPrefixRegex.Match(sql);
// WITH ile baslayan ya da beklenmeyen bicimdeki sorgularda sarmalama yapilmaz: SQL
// Server'da ORDER BY tasiyan bir alt sorgu gecersizdir. Sonuc kumesi okunurken yine
// maxRows satirda kesilir.
return match.Success
? string.Concat(sql.AsSpan(0, match.Length), $"TOP ({maxRows}) ", sql.AsSpan(match.Length))
: sql;
}
2026-10-10 22:03:36 +00:00
public McpOutcome<Dictionary<string, object>> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters)
2026-09-17 09:01:14 +00:00
{
var normalized = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
if (parameters is null)
{
return normalized;
}
foreach (var (key, value) in parameters)
{
if (key is null || !SafeParameterNameRegex.IsMatch(key))
{
2026-10-10 22:03:36 +00:00
return McpProblem.From(localizer, McpErrorCodes.InvalidParameterName, key ?? string.Empty);
2026-09-17 09:01:14 +00:00
}
normalized[key.TrimStart('@')] = value!;
}
return normalized;
}
}