sozsoft-platform/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlGuard.cs
2026-09-10 17:40:43 +03:00

175 lines
6.9 KiB
C#

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;
/// <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
{
/// <summary>Tek bir okuma ifadesi oldugunu dogrular; degilse anlasilir bir hata atar.</summary>
void EnsureReadOnlyStatement(string sql);
/// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary>
string QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType);
/// <summary>Saglayicinin varsayilan semasi.</summary>
string DefaultSchemaName(DataSourceTypeEnum dataSourceType);
/// <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>
Dictionary<string, object> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters);
}
public class McpSqlGuard(IStringLocalizer<PlatformResource> localizer) : DomainService, IMcpSqlGuard
{
/// <summary>Tek parcali SQL tanimlayicisi; sema ve nesne adlari bu kalibi gecmek zorundadir.</summary>
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);
/// <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);
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<string, object> NormalizeParameters(IReadOnlyDictionary<string, object?>? parameters)
{
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))
{
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]);
}
}
}