diff --git a/SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs b/SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs new file mode 100644 index 0000000..87e008f --- /dev/null +++ b/SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs @@ -0,0 +1,124 @@ +using System.Net; +using System.Net.Sockets; + +namespace SfeduSchedule.Middleware; + +/// +/// Middleware ограничивает доступ к endpoint'у (сделано для /metrics) только приватными сетями. +/// Допускаются: loopback, RFC1918 (10/8, 172.16/12, 192.168/16), link-local (169.254/16, IPv6 link-local), +/// а также уникальные локальные адреса IPv6 (fc00::/7). Любой другой источник получает 403. +/// Только метод GET. +/// +public class LocalNetworksOnlyMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + // Разрешаем только GET + if (!HttpMethods.IsGet(context.Request.Method)) + { + _logger.LogWarning("Metrics method not allowed: {Method} {Path}", context.Request.Method, context.Request.Path); + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + context.Response.Headers["Allow"] = "GET"; + await context.Response.WriteAsync("Method Not Allowed. Only GET is supported for metrics."); + return; + } + + // Получаем реальный клиентский IP. Если есть X-Forwarded-For, берём самый первый IP из списка. + var ip = ExtractClientIp(context) ?? context.Connection.RemoteIpAddress; + // Если пришёл IPv4, инкапсулированный в IPv6 (::ffff:x.y.z.w), разворачиваем в чистый IPv4. + if (ip is { IsIPv4MappedToIPv6: true }) + { + ip = ip.MapToIPv4(); + } + + // Проверяем принадлежность IP локальным/приватным сетям. + if (ip is null || !IsLocalNetwork(ip)) + { + // Фиксируем X-Forwarded-For (если есть) для диагностики за обратными прокси. + var xff = context.Request.Headers.TryGetValue("X-Forwarded-For", out var xffVal) ? xffVal.ToString() : null; + _logger.LogWarning("Metrics access forbidden. RemoteIP={RemoteIP}, XFF={XFF}, Path={Path}", ip?.ToString() ?? "null", xff, context.Request.Path); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync("Forbidden: metrics available only from local networks"); + return; + } + + // Продолжаем конвейер, если IP допустим. + await _next(context); + } + + /// + /// Определяет, принадлежит ли адрес локальным / приватным диапазонам. + /// + private static bool IsLocalNetwork(IPAddress ip) + { + // Loopback (127.0.0.0/8, ::1) + if (IPAddress.IsLoopback(ip)) + return true; + + if (ip.AddressFamily == AddressFamily.InterNetwork) + { + var b = ip.GetAddressBytes(); + // RFC1918: 10.0.0.0/8 + if (b[0] == 10) return true; + // RFC1918: 172.16.0.0 – 172.31.255.255 (172.16/12) + if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; + // RFC1918: 192.168.0.0/16 + if (b[0] == 192 && b[1] == 168) return true; + // RFC3927: link-local 169.254.0.0/16 (APIPA) + if (b[0] == 169 && b[1] == 254) return true; // link-local + return false; + } + + if (ip.AddressFamily == AddressFamily.InterNetworkV6) + { + // Link-local (fe80::/10) + if (ip.IsIPv6LinkLocal) return true; + var b = ip.GetAddressBytes(); + // ULA (Unique Local Address) RFC4193: fc00::/7 (fc00 – fdff) + if (b.Length > 0 && (b[0] == 0xFC || b[0] == 0xFD)) return true; // ULA + return false; + } + + return false; + } + + /// + /// Извлекает IP клиента из заголовка X-Forwarded-For (если присутствует). Берется первый IP. + /// Возвращает null, если заголовок отсутствует или содержит некорректные значения. + /// + private static IPAddress? ExtractClientIp(HttpContext context) + { + if (!context.Request.Headers.TryGetValue("X-Forwarded-For", out var xffValues)) + return null; + + var xff = xffValues.ToString(); + if (string.IsNullOrWhiteSpace(xff)) + return null; + + // Формат может быть: "client, proxy1, proxy2" — берём первый + var first = xff.Split(',')[0].Trim(); + if (string.IsNullOrEmpty(first)) + return null; + + // Возможны IPv6 адреса в квадратных скобках [::1] + if (first.StartsWith("[") && first.EndsWith("]")) + first = first.Substring(1, first.Length - 2); + + // Возможен порт через ':' в IPv4, удалим порт если он указан (для IPv6 двоеточия являются частью адреса) + if (first.Count(c => c == ':') == 1 && first.Contains('.') && first.Contains(':')) + { + first = first.Split(':')[0]; + } + + return IPAddress.TryParse(first, out var parsed) ? parsed : null; + } +} diff --git a/SfeduSchedule/Program.cs b/SfeduSchedule/Program.cs index dc91579..89cae64 100644 --- a/SfeduSchedule/Program.cs +++ b/SfeduSchedule/Program.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts; using SfeduSchedule.Auth; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Prometheus; +using SfeduSchedule.Middleware; var builder = WebApplication.CreateBuilder(args); @@ -193,7 +194,8 @@ builder.Services.AddRateLimiter(options => builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | - ForwardedHeaders.XForwardedProto; + ForwardedHeaders.XForwardedProto | + ForwardedHeaders.XForwardedHost; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); @@ -207,10 +209,8 @@ var app = builder.Build(); var logger = app.Services.GetRequiredService>(); -app.UseForwardedHeaders(new ForwardedHeadersOptions -{ - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost -}); +// Используем настройки из DI (Configure) +app.UseForwardedHeaders(); if (string.IsNullOrEmpty(preinstalledJwtToken)) { @@ -269,6 +269,14 @@ app.MapGet("/", async context => app.MapControllers(); +// Ограничим доступ к /metrics только локальными сетями +app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase), branch => +{ + branch.UseMiddleware(); +}); + +app.MapMetrics(); + // Маршруты Minimal API из плагинов foreach (var p in loadedPlugins) {