using Deal.Api.Configuration;
using Microsoft.Net.Http.Headers;
namespace Deal.Api.Middleware;
///
/// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): запросы не-GET/HEAD/OPTIONS к /api,
/// у которых есть заголовок Origin, обязаны иметь Origin, равный «своему» origin запроса
/// (схема + Host; за Caddy схема — https из X-Forwarded-Proto, см. UseForwardedHeaders) либо входящий
/// в явный allowlist ; несовпадение — HTTP 403
/// {"detail":"…"}.
///
///
/// Дополнительный слой 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).
///
/// Регистрируется последним из security-слоёв (Ruling 5: Session → Operator → RateLimiter → OriginGuard):
/// сессии уже разрешены, rate-limiter ответил 429 раньше, чем проверяется Origin.
///
///
public sealed class OriginGuardMiddleware
{
///
/// Текст 403 Origin-проверки (Ruling 10(2)): единая формулировка для всех отказов.
///
public const string OriginRejectedDetail = "Запрос отклонён: недопустимый Origin";
// Префикс пути, под которым живут все HTTP-эндпоинты приложения (Ruling 11).
private const string ApiPathPrefix = "/api";
private readonly RequestDelegate _next;
private readonly HashSet _allowedOrigins;
public OriginGuardMiddleware(RequestDelegate next, SecurityOptions securityOptions)
{
ArgumentNullException.ThrowIfNull(next);
ArgumentNullException.ThrowIfNull(securityOptions);
_next = next;
// Регистронезависимо: схема/хост origin'ов регистронечувствительны, браузеры шлют нижний регистр.
_allowedOrigins = new HashSet(securityOptions.AllowedOrigins, StringComparer.OrdinalIgnoreCase);
}
///
/// Обрабатывает запрос: отклоняет мутации /api с чужим Origin (403 {detail}).
///
/// Контекст запроса.
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;
}
}