using System; using System.Collections.Generic; using System.Text.RegularExpressions; using Microsoft.Extensions.Localization; using Sozsoft.Platform.Enums; using Sozsoft.Platform.Localization; using Volo.Abp; using Volo.Abp.Domain.Services; namespace Sozsoft.Mcp.Services; /// /// 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. /// public interface IMcpSqlGuard { /// Tek bir okuma ifadesi oldugunu dogrular; degilse anlasilir bir hata atar. void EnsureReadOnlyStatement(string sql); /// Sema/nesne adini dogrular ve saglayiciya gore tirnaklar. string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType); /// Saglayicinin varsayilan semasi. string DefaultSchemaName(DataSourceTypeEnum dataSourceType); /// /// Satir tavanini sorgunun kendisine yazar: SQL Server'da TOP (n), PostgreSQL'de /// LIMIT n. Sorgu kendi sinirini zaten tasiyorsa dokunulmaz. /// string ApplyRowLimit(string sql, int maxRows, DataSourceTypeEnum dataSourceType); /// /// Istemciden gelen parametre adlarini dogrular ve bastaki @ isaretini duserek Dapper'in /// bekledigi bicime cevirir. /// Dictionary NormalizeParameters(IReadOnlyDictionary? parameters); } public class McpSqlGuard(IStringLocalizer localizer) : DomainService, IMcpSqlGuard { /// Tek parcali SQL tanimlayicisi; sema ve nesne adlari bu kalibi gecmek zorundadir. private static readonly Regex SafeIdentifierRegex = new( @"^[A-Za-z_][A-Za-z0-9_]{0,127}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); 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); /// /// 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. /// 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); public void EnsureReadOnlyStatement(string sql) { if (string.IsNullOrWhiteSpace(sql)) { throw new UserFriendlyException(localizer[McpErrorCodes.EmptyQuery]); } var stripped = SqlStringLiteralRegex.Replace(SqlCommentRegex.Replace(sql, " "), "''"); if (stripped.Contains(';', StringComparison.Ordinal)) { throw new UserFriendlyException(localizer[McpErrorCodes.SingleStatementOnly]); } var trimmed = stripped.TrimStart(); var startsWithRead = trimmed.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) || trimmed.StartsWith("WITH", StringComparison.OrdinalIgnoreCase); if (!startsWithRead) { throw new UserFriendlyException(localizer[McpErrorCodes.ReadOnlyStatementRequired]); } var forbidden = ForbiddenStatementRegex.Match(stripped); if (forbidden.Success) { throw new UserFriendlyException( localizer[McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant()]); } } public string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType) { EnsureSafeIdentifier(schemaName); EnsureSafeIdentifier(objectName); return dataSourceType == DataSourceTypeEnum.Postgresql ? $"\"{schemaName}\".\"{objectName}\"" : $"[{schemaName}].[{objectName}]"; } public string DefaultSchemaName(DataSourceTypeEnum dataSourceType) => dataSourceType == DataSourceTypeEnum.Postgresql ? "public" : "dbo"; 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; } public Dictionary NormalizeParameters(IReadOnlyDictionary? parameters) { var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); if (parameters is null) { return normalized; } foreach (var (key, value) in parameters) { if (key is null || !SafeParameterNameRegex.IsMatch(key)) { throw new UserFriendlyException(localizer[McpErrorCodes.InvalidParameterName, key ?? string.Empty]); } normalized[key.TrimStart('@')] = value!; } return normalized; } private void EnsureSafeIdentifier(string identifier) { if (!SafeIdentifierRegex.IsMatch(identifier ?? string.Empty)) { throw new UserFriendlyException(localizer[McpErrorCodes.InvalidIdentifier, identifier ?? string.Empty]); } } }