food-market/src/food-market.api/Infrastructure/Security/SecurityHeadersMiddleware.cs
nns 8e54e2e0d6 feat(s13): security headers + rate-limits + sensitive-ops audit + session revoke + Grafana
Sprint 13 — security + observability deep. 7 пунктов чек-листа ✓.

Подробности — docs/sprint13-progress.md и docs/food-market-server-postgres-role.md.

Главное:
- food-market-server (back.food-market.kz, legacy backend) теперь
  работает на dedicated PG-роли food_market_server_app (NOSUPERUSER /
  NOCREATEDB / NOCREATEROLE / NOREPLICATION / NOBYPASSRLS) с CRUD-only
  грантами. Раньше использовался postgres-superuser с паролем 1q2w3e4r.
  Бэкап конфига сохранён, rollback одной командой.
- SecurityHeadersMiddleware навешивает CSP / X-Frame-Options DENY /
  X-Content-Type-Options nosniff / Referrer-Policy strict-origin /
  Permissions-Policy. HSTS 365d + includeSubDomains + preload.
  Те же заголовки в deploy/nginx.conf для SPA HTML.
- Rate-limit:
  • Signup-IP — 3/час + 10/день (на stage'е переопределено через
    .env RATE_SIGNUP_HOUR=30 чтобы не ломать e2e).
  • Forgot-password — per-email 3/час + per-IP 10/час.
- SensitiveOpsAudit сервис, wired в:
  • TwoFactor enroll/disable
  • Employees.Update при смене RoleId (action=AssignRole,
    payload с prev/next role + полный RolePermissions)
  • MeAccount.ChangePassword (новый endpoint)
  • MeSessions.RevokeAll (новый endpoint)
- POST /api/me/sessions/revoke-all — через
  IOpenIddictAuthorizationManager.FindBySubjectAsync + TryRevokeAsync.
  Integration-тест: refresh после revoke → 400/401.
- Hangfire dashboard — nginx-route добавлен (раньше /hangfire ловился
  SPA-fallback'ом). Фильтр SuperAdmin'ом уже был. Тест: anon/tenant →
  401/403/404.
- Grafana dashboard JSON (deploy/grafana/dashboards/food-market.json,
  9 панелей) + инструкции импорта в docs/observability.md.

Проверено на stage'е: все 6 security-заголовков видны на /;
/hangfire → 401 (закрыт); 4-я форгот → 429; stage-smoke (5 этапов) ✓.

Тесты: 68 unit + 9 integration (включая 3 новых: SessionRevokeTests,
HangfireAccessTests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 12:30:10 +05:00

116 lines
5.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

namespace foodmarket.Api.Infrastructure.Security;
/// <summary>Sprint 13 — навешивает security-заголовки на все ответы:
/// <list type="bullet">
/// <item>Content-Security-Policy — default-src/script-src/connect-src/img-src.</item>
/// <item>X-Frame-Options: DENY — фрейминг запрещён (защита от clickjacking).</item>
/// <item>X-Content-Type-Options: nosniff — браузер не «угадывает» MIME.</item>
/// <item>Referrer-Policy: strict-origin-when-cross-origin — не светим путь
/// внутреннего URL'а на внешние ресурсы.</item>
/// <item>Permissions-Policy — отключаем доступ к камерам/микрофонам/GPS.</item>
/// </list>
///
/// <para>Заголовки выставляются как «при первом write'е» (через
/// <c>OnStarting</c>), чтобы middleware'ы выше по pipeline (например,
/// LogEnrichment, ReadonlyOverride) могли при необходимости их
/// переопределить раньше отправки.</para>
///
/// <para>Исключения по path:
/// - <c>/metrics</c> — Prometheus scrape, заголовки не нужны.
/// - <c>/health/*</c> — health checks, не нужны.
/// - <c>/swagger/*</c> — Swagger UI требует более широкий CSP
/// (inline-eval), для него заголовки не ставим в Development. На
/// prod Swagger отключён, так что условие безопасно.</para></summary>
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;
}
}
/// <summary>Конфиг security-заголовков, в первую очередь — CSP.
/// CSP может потребовать тюнинга на конкретный environment (например,
/// stage с дополнительным CDN-доменом для аналитики). Переопределяется
/// через <c>Security:ContentSecurityPolicy</c> в appsettings.</summary>
public class SecurityHeadersOptions
{
public string ContentSecurityPolicy { get; set; } = DefaultCsp;
/// <summary>Дефолтный CSP — рассчитан на:
/// <list type="bullet">
/// <item>SPA + API на одном origin.</item>
/// <item>SignalR через wss:// (production) и ws:// (development/stage
/// proxied через nginx, но тоже терминирующийся в wss).</item>
/// <item>Inline styles из шаблонов писем и Tailwind base.</item>
/// <item>Картинки товаров: на загрузке через MinIO/S3 → data: и blob:
/// для предпросмотра; на просмотре товара — нативный self.</item>
/// </list>
/// Если понадобится включить Yandex.Metrica / Sentry / Datadog — добавить
/// домены в <c>script-src</c>, <c>connect-src</c>.</summary>
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'";
}