namespace foodmarket.Api.Infrastructure.Security;
/// Sprint 13 — навешивает security-заголовки на все ответы:
///
/// - Content-Security-Policy — default-src/script-src/connect-src/img-src.
/// - X-Frame-Options: DENY — фрейминг запрещён (защита от clickjacking).
/// - X-Content-Type-Options: nosniff — браузер не «угадывает» MIME.
/// - Referrer-Policy: strict-origin-when-cross-origin — не светим путь
/// внутреннего URL'а на внешние ресурсы.
/// - Permissions-Policy — отключаем доступ к камерам/микрофонам/GPS.
///
///
/// Заголовки выставляются как «при первом write'е» (через
/// OnStarting), чтобы middleware'ы выше по pipeline (например,
/// LogEnrichment, ReadonlyOverride) могли при необходимости их
/// переопределить раньше отправки.
///
/// Исключения по path:
/// - /metrics — Prometheus scrape, заголовки не нужны.
/// - /health/* — health checks, не нужны.
/// - /swagger/* — Swagger UI требует более широкий CSP
/// (inline-eval), для него заголовки не ставим в Development. На
/// prod Swagger отключён, так что условие безопасно.
public class SecurityHeadersMiddleware
{
private readonly RequestDelegate _next;
private readonly SecurityHeadersOptions _opts;
public SecurityHeadersMiddleware(RequestDelegate next, SecurityHeadersOptions opts)
{
_next = next;
_opts = opts;
}
public Task InvokeAsync(HttpContext ctx)
{
var path = ctx.Request.Path.Value ?? "";
if (ShouldSkip(path))
return _next(ctx);
ctx.Response.OnStarting(() =>
{
var h = ctx.Response.Headers;
// CSP: один заголовок, директивы через ';'. unsafe-inline
// в script-src нужен потому, что SignalR negotiate-handshake
// и Tailwind v4 injected styles бьют inline-стили; nonces
// через middleware не реализованы.
if (!h.ContainsKey("Content-Security-Policy"))
h["Content-Security-Policy"] = _opts.ContentSecurityPolicy;
if (!h.ContainsKey("X-Frame-Options"))
h["X-Frame-Options"] = "DENY";
if (!h.ContainsKey("X-Content-Type-Options"))
h["X-Content-Type-Options"] = "nosniff";
if (!h.ContainsKey("Referrer-Policy"))
h["Referrer-Policy"] = "strict-origin-when-cross-origin";
if (!h.ContainsKey("Permissions-Policy"))
h["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(), payment=(), usb=()";
// X-Permitted-Cross-Domain-Policies — старый Adobe-полиси, на
// всякий случай блокируем (не критично, но не повредит).
if (!h.ContainsKey("X-Permitted-Cross-Domain-Policies"))
h["X-Permitted-Cross-Domain-Policies"] = "none";
return Task.CompletedTask;
});
return _next(ctx);
}
private static bool ShouldSkip(string path)
{
if (path.StartsWith("/metrics", StringComparison.OrdinalIgnoreCase)) return true;
if (path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)) return true;
// Swagger UI требует более широкую CSP — для него не ставим.
// На prod Swagger выключен (см. Program.cs IncludeSwagger).
if (path.StartsWith("/swagger", StringComparison.OrdinalIgnoreCase)) return true;
return false;
}
}
/// Конфиг security-заголовков, в первую очередь — CSP.
/// CSP может потребовать тюнинга на конкретный environment (например,
/// stage с дополнительным CDN-доменом для аналитики). Переопределяется
/// через Security:ContentSecurityPolicy в appsettings.
public class SecurityHeadersOptions
{
public string ContentSecurityPolicy { get; set; } = DefaultCsp;
/// Дефолтный CSP — рассчитан на:
///
/// - SPA + API на одном origin.
/// - SignalR через wss:// (production) и ws:// (development/stage
/// proxied через nginx, но тоже терминирующийся в wss).
/// - Inline styles из шаблонов писем и Tailwind base.
/// - Картинки товаров: на загрузке через MinIO/S3 → data: и blob:
/// для предпросмотра; на просмотре товара — нативный self.
///
/// Если понадобится включить Yandex.Metrica / Sentry / Datadog — добавить
/// домены в script-src, connect-src.
public const string DefaultCsp =
"default-src 'self'; " +
"script-src 'self' 'unsafe-inline'; " +
"style-src 'self' 'unsafe-inline'; " +
"connect-src 'self' wss: ws:; " +
"img-src 'self' data: blob:; " +
"font-src 'self' data:; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'";
}