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