Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
@@ -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;
}
}