Правило distinct_logins_per_ip (перебор логинов с одного IP) в SuspiciousActivityService; real-time учёт SuspiciousActivityReporter: метрика deal.security.suspicious{kind} и warn-лог на 429/блокировке входа. Закрывает BL-SUSPICIOUS.
534 lines
23 KiB
C#
534 lines
23 KiB
C#
using System.Net;
|
||
using System.Net.Sockets;
|
||
using System.Text.Encodings.Web;
|
||
using Deal.Api.Configuration;
|
||
using Deal.Api.Endpoints;
|
||
using Deal.Api.Events;
|
||
using Deal.Api.Hosting;
|
||
using Deal.Api.Logging;
|
||
using Deal.Api.Middleware;
|
||
using Deal.Api.Observability;
|
||
using Deal.Api.Services;
|
||
using Deal.Api.Sources;
|
||
using Deal.Api.Telegram;
|
||
using Deal.Contracts.Integrations.Abstractions;
|
||
using Deal.Infrastructure;
|
||
using Deal.Infrastructure.Data;
|
||
using Deal.Infrastructure.Integrations.Models;
|
||
using Deal.Infrastructure.Integrations.Options;
|
||
using Deal.Infrastructure.Integrations.Services;
|
||
using Deal.Infrastructure.Integrations.Storage.Services;
|
||
using Deal.Infrastructure.Persistence;
|
||
using Deal.Infrastructure.Services;
|
||
using Deal.Modules.Discovery.Application.Registrars;
|
||
using Deal.Modules.Kanban.Application.Registrars;
|
||
using Deal.Modules.Pipeline.Application.Registrars;
|
||
using Deal.Modules.Settings.Application.Abstractions;
|
||
using Deal.Modules.Settings.Application.Registrars;
|
||
using Deal.Modules.Telegram.Application;
|
||
using Deal.Modules.Tenants.Application.Models;
|
||
using Deal.Modules.Tenants.Application.Registrars;
|
||
using Deal.SharedKernel.Tenants.Abstractions;
|
||
using Microsoft.AspNetCore.HttpOverrides;
|
||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||
using Microsoft.AspNetCore.Server.Kestrel.Https;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||
// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом.
|
||
using CookieOptions = Deal.Api.Configuration.CookieOptions;
|
||
|
||
const string cookiesSectionName = "Cookies";
|
||
const string operatorCookiesSectionName = "OperatorCookies";
|
||
const string corsPolicyName = "cors";
|
||
const string servicesSectionName = "Services:Ml";
|
||
const string aiServicesSectionName = "Services:Ai";
|
||
const string telegramServicesSectionName = "Services:Telegram";
|
||
// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет
|
||
// TenantProvisioningService на старте, runtime-контекст их не выполняет).
|
||
const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory";
|
||
const int defaultIngressPort = 5082;
|
||
const string ingressPortEnvKey = "GRPC_INGRESS_PORT";
|
||
// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings).
|
||
const string serverUrlsKey = "urls";
|
||
// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000).
|
||
const string defaultHttpUrl = "http://localhost:5000";
|
||
const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET";
|
||
const string rateLimitSectionName = "RateLimit";
|
||
const string dataRetentionSectionName = "DataRetention";
|
||
const string securitySectionName = "Security";
|
||
const string forwardedHeadersSectionName = "ForwardedHeaders";
|
||
|
||
const string coreProcessName = "core";
|
||
|
||
var builder = WebApplication.CreateBuilder(args);
|
||
|
||
DealLogging.Configure(builder, coreProcessName);
|
||
|
||
int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort);
|
||
DealMetricsHosting.AddDealMetrics(builder, metricsPort);
|
||
|
||
// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует
|
||
// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД.
|
||
var connectionString = builder.Configuration.GetConnectionString("DealPostgres")
|
||
?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан");
|
||
builder.Services.AddDbContext<DealDbContext>(options => options.UseNpgsql(connectionString));
|
||
builder.Services.AddSingleton<ITenantContext, TenantContext>();
|
||
builder.Services.AddSingleton<ConnectionStringProvider>();
|
||
|
||
MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration);
|
||
MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions);
|
||
if (mtlsCertificates is not null)
|
||
{
|
||
builder.Services.AddSingleton(mtlsCertificates);
|
||
}
|
||
|
||
builder.WebHost.ConfigureKestrel(kestrel =>
|
||
{
|
||
BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]);
|
||
int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort;
|
||
kestrel.ListenAnyIP(ingressPort, listen =>
|
||
{
|
||
listen.Protocols = HttpProtocols.Http2;
|
||
if (mtlsCertificates is not null)
|
||
{
|
||
listen.UseHttps(https =>
|
||
{
|
||
https.ServerCertificate = mtlsCertificates.ServerCertificate;
|
||
https.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
|
||
https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate;
|
||
});
|
||
}
|
||
});
|
||
});
|
||
|
||
// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка
|
||
// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware)
|
||
// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса
|
||
// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton.
|
||
// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации.
|
||
builder.Services.AddDbContext<TenantDbContext>(
|
||
(serviceProvider, options) =>
|
||
{
|
||
var tenantContext = serviceProvider.GetRequiredService<ITenantContext>();
|
||
if (!tenantContext.HasTenant)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия "
|
||
+ "(ITenantContext.HasTenant == false).");
|
||
}
|
||
|
||
var connectionStringProvider = serviceProvider.GetRequiredService<ConnectionStringProvider>();
|
||
options.UseNpgsql(
|
||
connectionStringProvider.ForTenant(tenantContext.TenantId),
|
||
npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable));
|
||
},
|
||
contextLifetime: ServiceLifetime.Scoped,
|
||
optionsLifetime: ServiceLifetime.Scoped);
|
||
|
||
builder.Services.AddTenantsModule();
|
||
|
||
TokenLimitDefaults tenantLimitDefaults = new(
|
||
ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod);
|
||
builder.Services.AddDealPersistence(tenantLimitDefaults);
|
||
|
||
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
|
||
|
||
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
|
||
builder.Services.AddSingleton(mlOptions);
|
||
AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get<AiServiceOptions>() ?? new AiServiceOptions();
|
||
builder.Services.AddSingleton(aiOptions);
|
||
TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get<TelegramServiceOptions>() ?? new TelegramServiceOptions();
|
||
builder.Services.AddSingleton(telegramOptions);
|
||
builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates);
|
||
|
||
builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates));
|
||
|
||
builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath);
|
||
|
||
builder.Services.AddSettingsModule();
|
||
|
||
builder.Services.AddKanbanModule();
|
||
|
||
builder.Services.AddPipelineModule();
|
||
|
||
builder.Services.AddTelegramModule();
|
||
|
||
builder.Services.AddDiscoveryModule();
|
||
|
||
builder.Services.AddScoped<TgStatusService>();
|
||
builder.Services.AddScoped<TelegramKeysService>();
|
||
|
||
builder.Services.AddSingleton<TelegramBackfillScheduler>();
|
||
|
||
builder.Services.AddScoped<AdminTickOrchestrator>();
|
||
|
||
builder.Services.AddScoped<FtsMaintenance>();
|
||
|
||
builder.Services.AddSingleton<SseBroker>();
|
||
|
||
builder.Services.AddSingleton<StorageToastPublisher>();
|
||
|
||
builder.Services.AddSingleton<PipelinePumpGate>();
|
||
|
||
RateLimitOptions rateLimitOptions = builder.Configuration
|
||
.GetSection(rateLimitSectionName)
|
||
.Get<RateLimitOptions>() ?? new RateLimitOptions();
|
||
builder.Services.AddSingleton(rateLimitOptions);
|
||
// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер
|
||
// (public.rate_limit_counters). Активен только при Enabled (no-op иначе).
|
||
builder.Services.AddScoped<LoginAttemptGuard>();
|
||
builder.Services.AddScoped<SuspiciousActivityReporter>();
|
||
if (rateLimitOptions.Enabled)
|
||
{
|
||
builder.Services.AddDealRateLimiter(rateLimitOptions);
|
||
}
|
||
|
||
builder.Services.AddGrpc(grpc =>
|
||
{
|
||
grpc.Interceptors.Add<RpcCallLoggingInterceptor>();
|
||
grpc.Interceptors.Add<IngressServiceTokenInterceptor>();
|
||
if (rateLimitOptions.Enabled)
|
||
{
|
||
grpc.Interceptors.Add<IngressRateLimitInterceptor>();
|
||
}
|
||
});
|
||
if (rateLimitOptions.Enabled)
|
||
{
|
||
builder.Services.AddSingleton(provider =>
|
||
IngressRateLimitInterceptor.CreateLimiter(
|
||
provider.GetRequiredService<IServiceScopeFactory>(),
|
||
rateLimitOptions.GrpcIngressPerMinute));
|
||
}
|
||
|
||
builder.Services.AddScoped<TelegramIngressService>();
|
||
builder.Services.AddScoped<IngressTenantResolver>();
|
||
builder.Services.AddScoped<SourceIngressGrpcService>();
|
||
|
||
builder.Services
|
||
.AddGrpcHealthChecks()
|
||
.AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов"));
|
||
|
||
builder.Services.AddHttpClient<IAiConnectionChecker, AiConnectionChecker>(
|
||
client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds));
|
||
|
||
builder.Services.AddHttpClient<IRatesSource, CbrRateSource>(
|
||
client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds));
|
||
|
||
builder.Services.AddSingleton<RatesRefreshScheduler>();
|
||
|
||
builder.Services.AddHostedService<TenantBootstrapService>();
|
||
|
||
builder.Services.AddHostedService<OperatorBootstrapHostedService>();
|
||
|
||
builder.Services.AddHostedService<StorageTickScheduler>();
|
||
|
||
builder.Services.AddHostedService<BudgetAlertScheduler>();
|
||
|
||
builder.Services.AddHostedService<PipelineWorkerScheduler>();
|
||
|
||
if (!mlOptions.UseLocal)
|
||
{
|
||
builder.Services.AddHostedService<MlOutboxFlushScheduler>();
|
||
}
|
||
|
||
builder.Services.AddHostedService<DiscoveryWorkerScheduler>();
|
||
|
||
builder.Services.AddSingleton<RuntimeDepthsCollector>();
|
||
|
||
builder.Services.AddHostedService<DealMetricsCollector>();
|
||
|
||
DataRetentionOptions dataRetentionOptions = builder.Configuration
|
||
.GetSection(dataRetentionSectionName)
|
||
.Get<DataRetentionOptions>() ?? new DataRetentionOptions();
|
||
builder.Services.AddSingleton(dataRetentionOptions);
|
||
builder.Services.AddHostedService<DataRetentionScheduler>();
|
||
|
||
// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*).
|
||
builder.Services.Configure<CookieOptions>(builder.Configuration.GetSection(cookiesSectionName));
|
||
|
||
builder.Services.Configure<OperatorCookieOptions>(builder.Configuration.GetSection(operatorCookiesSectionName));
|
||
|
||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||
options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping);
|
||
|
||
SecurityOptions securityOptions = builder.Configuration
|
||
.GetSection(securitySectionName)
|
||
.Get<SecurityOptions>() ?? new SecurityOptions();
|
||
builder.Services.AddSingleton(securityOptions);
|
||
|
||
ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration
|
||
.GetSection(forwardedHeadersSectionName)
|
||
.Get<ForwardedHeadersConfig>() ?? new ForwardedHeadersConfig();
|
||
builder.Services.AddSingleton(forwardedHeadersConfig);
|
||
|
||
builder.Services.AddCors(options =>
|
||
options.AddPolicy(corsPolicyName, cors =>
|
||
{
|
||
cors.AllowAnyHeader()
|
||
.AllowAnyMethod()
|
||
.AllowCredentials();
|
||
if (securityOptions.AllowedOrigins.Length == 0)
|
||
{
|
||
cors.SetIsOriginAllowed(_ => true);
|
||
}
|
||
else
|
||
{
|
||
cors.WithOrigins(securityOptions.AllowedOrigins);
|
||
}
|
||
}));
|
||
|
||
var app = builder.Build();
|
||
|
||
DealMetricsHosting.MapDealMetrics(app);
|
||
|
||
// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен,
|
||
// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не
|
||
// молча работает без лимитов/с открытым CORS.
|
||
if (app.Environment.IsProduction())
|
||
{
|
||
if (!rateLimitOptions.Enabled)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом).");
|
||
}
|
||
|
||
if (securityOptions.AllowedOrigins.Length == 0)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке).");
|
||
}
|
||
}
|
||
|
||
app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService<IFileStorage>());
|
||
|
||
app.Logger.LogInformation(
|
||
"ML-интеграция: {Mode} ({Endpoint})",
|
||
mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service",
|
||
mlOptions.Endpoint);
|
||
|
||
app.Logger.LogInformation(
|
||
"AI-интеграция: {Mode} ({Endpoint})",
|
||
aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service",
|
||
aiOptions.Endpoint);
|
||
|
||
app.Logger.LogInformation(
|
||
"Telegram-гейт: {Mode} ({Endpoint})",
|
||
telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service",
|
||
telegramOptions.Endpoint);
|
||
|
||
app.Logger.LogInformation(
|
||
"Транспорт внутреннего gRPC: {Transport}",
|
||
mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)");
|
||
|
||
if (forwardedHeadersConfig.Enabled)
|
||
{
|
||
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
|
||
}
|
||
|
||
app.UseMiddleware<HttpAccessLogMiddleware>();
|
||
|
||
app.UseCors(corsPolicyName);
|
||
app.UseMiddleware<SessionMiddleware>();
|
||
app.UseMiddleware<OperatorSessionMiddleware>();
|
||
if (rateLimitOptions.Enabled)
|
||
{
|
||
app.UseRateLimiter();
|
||
}
|
||
|
||
app.UseMiddleware<OriginGuardMiddleware>();
|
||
|
||
app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" }));
|
||
app.MapAuthEndpoints();
|
||
app.MapOperatorAuthEndpoints();
|
||
app.MapOperatorAuditEndpoints();
|
||
app.MapOperatorAnalyticsEndpoints();
|
||
app.MapOperatorInvitesEndpoints();
|
||
app.MapOperatorTenantsEndpoints();
|
||
app.MapOperatorLimitsEndpoints();
|
||
app.MapOperatorHealthEndpoints();
|
||
app.MapOperatorSettingsEndpoints();
|
||
app.MapOperatorMaintenanceEndpoints();
|
||
app.MapJoinEndpoint();
|
||
app.MapSettingsEndpoints();
|
||
app.MapAiCheckEndpoint();
|
||
app.MapRatesEndpoints();
|
||
app.MapMlEndpoints();
|
||
app.MapFilterTesterEndpoints();
|
||
app.MapContainersEndpoints();
|
||
app.MapCardsEndpoints();
|
||
app.MapCardDetailsEndpoints();
|
||
app.MapStorageEndpoints();
|
||
app.MapEventsEndpoint();
|
||
app.MapAiSuggestEndpoints();
|
||
app.MapPipelineEndpoints();
|
||
app.MapTelegramEndpoints();
|
||
app.MapTelegramQrImageEndpoint();
|
||
app.MapDiscoveryEndpoints();
|
||
app.MapGrpcService<TelegramIngressService>().DisableRateLimiting();
|
||
app.MapGrpcService<SourceIngressGrpcService>().DisableRateLimiting();
|
||
app.MapGrpcHealthChecksService().DisableRateLimiting();
|
||
|
||
app.Run();
|
||
|
||
// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ──
|
||
|
||
// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных
|
||
// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из
|
||
// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге.
|
||
static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig)
|
||
{
|
||
List<Uri> addresses = ParseHttpAddresses(urlsConfig);
|
||
if (addresses.Count == 0)
|
||
{
|
||
addresses.Add(new Uri(defaultHttpUrl));
|
||
}
|
||
|
||
foreach (Uri address in addresses)
|
||
{
|
||
// Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::»
|
||
// (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени).
|
||
bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
|
||
string host = address.Host.Trim('[', ']');
|
||
if (host is "0.0.0.0" or "*" or "+" or "::")
|
||
{
|
||
kestrel.Listen(IPAddress.Any, address.Port, listen =>
|
||
{
|
||
if (isHttps)
|
||
{
|
||
listen.UseHttps();
|
||
}
|
||
});
|
||
}
|
||
else if (isHttps)
|
||
{
|
||
kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps());
|
||
}
|
||
else
|
||
{
|
||
kestrel.ListenLocalhost(address.Port);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются.
|
||
static List<Uri> ParseHttpAddresses(string? urlsConfig)
|
||
{
|
||
var addresses = new List<Uri>();
|
||
if (string.IsNullOrWhiteSpace(urlsConfig))
|
||
{
|
||
return addresses;
|
||
}
|
||
|
||
foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||
{
|
||
if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri)
|
||
&& (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
|
||
{
|
||
addresses.Add(uri);
|
||
}
|
||
}
|
||
|
||
return addresses;
|
||
}
|
||
|
||
// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта).
|
||
static int? ParsePort(string? rawValue)
|
||
=> int.TryParse(rawValue, out int parsedPort) ? parsedPort : null;
|
||
|
||
long ResolveDefaultAiBudget(IConfiguration configuration)
|
||
{
|
||
string? rawValue = configuration[defaultAiBudgetEnvKey];
|
||
return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0
|
||
? parsedBudget
|
||
: TokenBudgetDefaults.DefaultBudgetTokens;
|
||
}
|
||
|
||
public partial class Program
|
||
{
|
||
/// <summary>
|
||
/// Строит опции UseForwardedHeaders из ForwardedHeadersConfig
|
||
/// </summary>
|
||
/// <param name="config">Секция ForwardedHeaders конфигурации.</param>
|
||
/// <returns>Опции для app.UseForwardedHeaders.</returns>
|
||
public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(config);
|
||
var options = new ForwardedHeadersOptions
|
||
{
|
||
// Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из
|
||
// X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи.
|
||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
|
||
// Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки.
|
||
ForwardLimit = 1,
|
||
};
|
||
// Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы
|
||
// перечисление в KnownProxies/KnownIPNetworks было единственным источником истины.
|
||
options.KnownProxies.Clear();
|
||
options.KnownIPNetworks.Clear();
|
||
foreach (string proxy in config.KnownProxies)
|
||
{
|
||
if (!IPAddress.TryParse(proxy, out IPAddress? address))
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\".");
|
||
}
|
||
|
||
options.KnownProxies.Add(address);
|
||
}
|
||
|
||
foreach (string network in config.KnownNetworks)
|
||
{
|
||
if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork))
|
||
{
|
||
throw new InvalidOperationException(
|
||
$"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" "
|
||
+ "(ожидается CIDR, напр. 172.16.0.0/12).");
|
||
}
|
||
|
||
options.KnownIPNetworks.Add(parsedNetwork);
|
||
}
|
||
|
||
if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0)
|
||
{
|
||
// Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять
|
||
// ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного
|
||
// конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD
|
||
// перечисляет Caddy адресами/подсетями в конфиге.
|
||
options.KnownProxies.Add(IPAddress.Loopback);
|
||
options.KnownProxies.Add(IPAddress.IPv6Loopback);
|
||
}
|
||
|
||
return options;
|
||
}
|
||
|
||
// Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся
|
||
// подсеть одного адреса (IPv4 — /32, IPv6 — /128).
|
||
// value: Строка конфига (ForwardedHeaders:KnownNetworks).
|
||
// network: Разобранная подсеть при успехе.
|
||
// Возвращает: true — запись валидна.
|
||
private static bool TryParseCidr(string value, out System.Net.IPNetwork network)
|
||
{
|
||
network = default;
|
||
int slashIndex = value.IndexOf('/');
|
||
string addressPart = slashIndex >= 0 ? value[..slashIndex] : value;
|
||
if (!IPAddress.TryParse(addressPart, out IPAddress? address))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
|
||
int prefixLength = maxPrefixLength;
|
||
if (slashIndex >= 0)
|
||
{
|
||
string lengthPart = value[(slashIndex + 1)..];
|
||
if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
|
||
network = new System.Net.IPNetwork(address, prefixLength);
|
||
return true;
|
||
}
|
||
}
|