175 lines
6.9 KiB
C#
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]);
|
|
}
|
|
}
|
|
}
|