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; } }