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>
116 lines
5.7 KiB
C#
116 lines
5.7 KiB
C#
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'";
|
||
}
|