diff --git a/SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs b/SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs
new file mode 100644
index 0000000..c0408cd
--- /dev/null
+++ b/SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs
@@ -0,0 +1,92 @@
+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;
+ }
+
+ var ip = 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;
+ }
+}
diff --git a/SfeduSchedule/Program.cs b/SfeduSchedule/Program.cs
index dc91579..3fbde9e 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);
@@ -269,6 +270,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)
{