Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
98 lines
5.3 KiB
C#
98 lines
5.3 KiB
C#
using Deal.Api.Configuration;
|
|
using Microsoft.Net.Http.Headers;
|
|
|
|
namespace Deal.Api.Middleware;
|
|
|
|
/// <summary>
|
|
/// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): запросы не-GET/HEAD/OPTIONS к /api,
|
|
/// у которых есть заголовок Origin, обязаны иметь Origin, равный «своему» origin запроса
|
|
/// (схема + Host; за Caddy схема — https из X-Forwarded-Proto, см. UseForwardedHeaders) либо входящий
|
|
/// в явный allowlist <see cref="SecurityOptions.AllowedOrigins"/>; несовпадение — HTTP 403
|
|
/// <c>{"detail":"…"}</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Дополнительный слой CSRF поверх SameSite=Lax кук (первый рубеж, документируется в техдок §10):
|
|
/// браузер всегда шлёт Origin на мутирующих запросах, а подделать его из чужого сайта нельзя, поэтому
|
|
/// «чужой» Origin — надёжный признак cross-site запроса. Запросы без Origin (curl, сервер-сервер,
|
|
/// gRPC) не проверяются и пропускаются — Origin обязателен только у браузерных вызовов. GET/HEAD —
|
|
/// не мутации, OPTIONS — CORS-preflight: не проверяются. Пустой allowlist (dev-режим, Ruling 10(2)) —
|
|
/// правило «Origin == свой origin запроса»; непустой список из конфига расширяет его (фронт за
|
|
/// прокси, меняющим Host, и/или явные домены PROD, Ruling 9).
|
|
/// <para>
|
|
/// Регистрируется последним из security-слоёв (Ruling 5: Session → Operator → RateLimiter → OriginGuard):
|
|
/// сессии уже разрешены, rate-limiter ответил 429 раньше, чем проверяется Origin.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class OriginGuardMiddleware
|
|
{
|
|
/// <summary>
|
|
/// Текст 403 Origin-проверки (Ruling 10(2)): единая формулировка для всех отказов.
|
|
/// </summary>
|
|
public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin";
|
|
|
|
// Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11).
|
|
private const string ApiPathPrefix = "/api";
|
|
|
|
private readonly RequestDelegate _next;
|
|
private readonly HashSet<string> _allowedOrigins;
|
|
|
|
public OriginGuardMiddleware(RequestDelegate next, SecurityOptions securityOptions)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(next);
|
|
ArgumentNullException.ThrowIfNull(securityOptions);
|
|
_next = next;
|
|
// Регистронезависимо: схема/хост origin'ов регистронечувствительны, браузеры шлют нижний регистр.
|
|
_allowedOrigins = new HashSet<string>(securityOptions.AllowedOrigins, StringComparer.OrdinalIgnoreCase);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}).
|
|
/// </summary>
|
|
/// <param name="context">Контекст запроса.</param>
|
|
public async Task InvokeAsync(HttpContext context)
|
|
{
|
|
HttpRequest request = context.Request;
|
|
if (request.Path.StartsWithSegments(ApiPathPrefix)
|
|
&& !HttpMethods.IsGet(request.Method)
|
|
&& !HttpMethods.IsHead(request.Method)
|
|
&& !HttpMethods.IsOptions(request.Method)
|
|
&& request.Headers.TryGetValue(HeaderNames.Origin, out var originValues))
|
|
{
|
|
string origin = originValues.ToString();
|
|
if (!string.IsNullOrWhiteSpace(origin) && !IsTrustedOrigin(origin, request))
|
|
{
|
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
|
await context.Response.WriteAsJsonAsync(new { detail = OriginRejectedDetail }, context.RequestAborted);
|
|
return;
|
|
}
|
|
}
|
|
|
|
await _next(context);
|
|
}
|
|
|
|
// Проверяет Origin: allowlist конфига либо «свой» origin запроса (схема + Host).
|
|
// origin: Значение заголовка Origin запроса.
|
|
// request: Запрос (схема/Host — с учётом прокси-заголовков, если UseForwardedHeaders включён).
|
|
// Возвращает: true — Origin доверенный, запрос можно пропускать.
|
|
private bool IsTrustedOrigin(string origin, HttpRequest request)
|
|
{
|
|
if (_allowedOrigins.Contains(origin))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
// «Свой» origin: схема + Host-заголовок запроса (без завершающего слэша — как шлёт браузер).
|
|
HostString host = request.Host;
|
|
if (host.HasValue)
|
|
{
|
|
string selfOrigin = request.Scheme + "://" + host.Value;
|
|
if (string.Equals(origin, selfOrigin, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|