sozsoft-platform/api/modules/Sozsoft.Mcp/Sozsoft.Mcp.Domain/Services/McpSqlGuard.cs
2026-10-11 01:03:36 +03:00

170 lines
6.8 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.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 nedenini dondurur.</summary>
McpProblem? CheckReadOnlyStatement(string sql);
/// <summary>Sema/nesne adini dogrular ve saglayiciya gore tirnaklar.</summary>
McpOutcome<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>
McpOutcome<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 McpProblem? CheckReadOnlyStatement(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
{
return McpProblem.From(localizer, McpErrorCodes.EmptyQuery);
}
var stripped = SqlStringLiteralRegex.Replace(SqlCommentRegex.Replace(sql, " "), "''");
if (stripped.Contains(';', StringComparison.Ordinal))
{
return McpProblem.From(localizer, McpErrorCodes.SingleStatementOnly);
}
var trimmed = stripped.TrimStart();
var startsWithRead = trimmed.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase)
|| trimmed.StartsWith("WITH", StringComparison.OrdinalIgnoreCase);
if (!startsWithRead)
{
return McpProblem.From(localizer, McpErrorCodes.ReadOnlyStatementRequired);
}
var forbidden = ForbiddenStatementRegex.Match(stripped);
return forbidden.Success
? McpProblem.From(localizer, McpErrorCodes.ForbiddenKeyword, forbidden.Value.ToUpperInvariant())
: null;
}
public McpOutcome<string> QuoteQualifiedName(string schemaName, string objectName, DataSourceTypeEnum dataSourceType)
{
foreach (var name in (string?[])[schemaName, objectName])
{
if (!SafeIdentifierRegex.IsMatch(name ?? string.Empty))
{
return McpProblem.From(localizer, McpErrorCodes.InvalidIdentifier, name ?? string.Empty);
}
}
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 McpOutcome<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))
{
return McpProblem.From(localizer, McpErrorCodes.InvalidParameterName, key ?? string.Empty);
}
normalized[key.TrimStart('@')] = value!;
}
return normalized;
}
}