Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user