Сделал ограничение доступа к /metrics
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 1m28s
All checks were successful
Create and publish a Docker image / Publish image (push) Successful in 1m28s
This commit is contained in:
124
SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs
Normal file
124
SfeduSchedule/Middleware/LocalNetworksOnlyMiddleware.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
namespace SfeduSchedule.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class LocalNetworksOnlyMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<LocalNetworksOnlyMiddleware> _logger;
|
||||||
|
|
||||||
|
public LocalNetworksOnlyMiddleware(RequestDelegate next, ILogger<LocalNetworksOnlyMiddleware> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Определяет, принадлежит ли адрес локальным / приватным диапазонам.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Извлекает IP клиента из заголовка X-Forwarded-For (если присутствует). Берется первый IP.
|
||||||
|
/// Возвращает null, если заголовок отсутствует или содержит некорректные значения.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.ApplicationParts;
|
|||||||
using SfeduSchedule.Auth;
|
using SfeduSchedule.Auth;
|
||||||
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
|
||||||
using Prometheus;
|
using Prometheus;
|
||||||
|
using SfeduSchedule.Middleware;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -193,7 +194,8 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor |
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor |
|
||||||
ForwardedHeaders.XForwardedProto;
|
ForwardedHeaders.XForwardedProto |
|
||||||
|
ForwardedHeaders.XForwardedHost;
|
||||||
options.KnownNetworks.Clear();
|
options.KnownNetworks.Clear();
|
||||||
options.KnownProxies.Clear();
|
options.KnownProxies.Clear();
|
||||||
|
|
||||||
@@ -207,10 +209,8 @@ var app = builder.Build();
|
|||||||
|
|
||||||
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
var logger = app.Services.GetRequiredService<ILogger<Program>>();
|
||||||
|
|
||||||
app.UseForwardedHeaders(new ForwardedHeadersOptions
|
// Используем настройки из DI (Configure<ForwardedHeadersOptions>)
|
||||||
{
|
app.UseForwardedHeaders();
|
||||||
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost
|
|
||||||
});
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(preinstalledJwtToken))
|
if (string.IsNullOrEmpty(preinstalledJwtToken))
|
||||||
{
|
{
|
||||||
@@ -269,6 +269,14 @@ app.MapGet("/", async context =>
|
|||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
// Ограничим доступ к /metrics только локальными сетями
|
||||||
|
app.UseWhen(ctx => ctx.Request.Path.StartsWithSegments("/metrics", StringComparison.OrdinalIgnoreCase), branch =>
|
||||||
|
{
|
||||||
|
branch.UseMiddleware<LocalNetworksOnlyMiddleware>();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapMetrics();
|
||||||
|
|
||||||
// Маршруты Minimal API из плагинов
|
// Маршруты Minimal API из плагинов
|
||||||
foreach (var p in loadedPlugins)
|
foreach (var p in loadedPlugins)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user