From a80471d0f947679008351258b68ab0341cf4d7dd Mon Sep 17 00:00:00 2001 From: nns Date: Tue, 9 Jun 2026 03:35:38 +0500 Subject: [PATCH] fix(security): add HSTS header on stage + integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Найдено в Sprint 28 security audit: stage отдаёт security-заголовки (CSP, X-Frame-Options, Referrer-Policy, Permissions-Policy и др.), но БЕЗ Strict-Transport-Security. HSTS из ASP.NET Core (Program.cs UseHsts) не срабатывает потому что api за nginx-прокси видит запрос как HTTP (нет ForwardedHeaders middleware'a; nginx X-Forwarded-Proto не дешифруется). Простейший фикс: добавить HSTS в deploy/nginx.conf (web-контейнер). Brower honors HSTS только на HTTPS-ответах — безопасно unconditional. max-age=2592000 (30 дней), без includeSubDomains и без preload — pre-emptive consent, можно безопасно убрать. Когда production stack устаканится и admin.food-market.kz будет подан в hstspreload.org — увеличить до 31536000 + preload + includeSubDomains. Verified: curl -I https://test.admin.food-market.kz/ | grep -i strict > strict-transport-security: max-age=2592000 Integration test 08-security-headers.spec.ts проверяет 7 security- заголовков на главной + на 404 (always-параметр). Cert: 10/10 integration tests passed in 1.3m. Co-Authored-By: Claude Opus 4.7 --- deploy/nginx.conf | 8 ++++ tests/integration/08-security-headers.spec.ts | 47 +++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100644 tests/integration/08-security-headers.spec.ts diff --git a/deploy/nginx.conf b/deploy/nginx.conf index ed6bf93..f8108f1 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -14,6 +14,14 @@ server { add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; add_header X-Permitted-Cross-Domain-Policies "none" always; + # Sprint 28: HSTS. Brower honors HSTS only on HTTPS responses, поэтому + # безопасно добавлять unconditionally — если клиент пришёл по HTTP, + # header игнорируется. Без includeSubDomains и без preload — это + # pre-emptive consent: можно безопасно убрать. Когда production stack + # устаканится и admin.food-market.kz будет подан в hstspreload.org, + # увеличить max-age до 31536000 + добавить preload и includeSubDomains. + add_header Strict-Transport-Security "max-age=2592000" always; + # Long-running admin imports (MoySklad etc.) read from upstream for tens of # minutes. Bump timeouts only on that path so normal API stays snappy. location /api/admin/import/ { diff --git a/tests/integration/08-security-headers.spec.ts b/tests/integration/08-security-headers.spec.ts new file mode 100644 index 0000000..aadde58 --- /dev/null +++ b/tests/integration/08-security-headers.spec.ts @@ -0,0 +1,47 @@ +/** + * Sprint 28 — security headers verification. + * + * Поверяет, что ВСЕ нужные security-заголовки выставлены на каждом + * ответе SPA, в т.ч. на 4xx/404. Регрессионная защита: если кто-то + * случайно уберёт `always` или `add_header` — этот тест поймает. + */ +import { expect, test } from '@playwright/test' +import { baseUrl } from '../regression/factories/api-client.js' + +const REQUIRED_HEADERS = [ + // Sprint 13. + 'content-security-policy', + 'x-frame-options', + 'x-content-type-options', + 'referrer-policy', + 'permissions-policy', + 'x-permitted-cross-domain-policies', + // Sprint 28. + 'strict-transport-security', +] + +test.describe('27.9 security headers', () => { + test('все обязательные security-заголовки на главной странице', async () => { + const r = await fetch(`${baseUrl}/`) + expect(r.status).toBeLessThan(500) + const headers = Object.fromEntries( + [...r.headers.entries()].map(([k, v]) => [k.toLowerCase(), v]), + ) + for (const h of REQUIRED_HEADERS) { + expect(headers[h], `missing header: ${h}`).toBeTruthy() + } + // HSTS специфика. + expect(headers['strict-transport-security']).toMatch(/max-age=\d+/) + }) + + test('заголовки сохраняются на 404 (always-параметр)', async () => { + const r = await fetch(`${baseUrl}/this-path-does-not-exist-deliberately`) + // Может быть 404 или 200 (SPA fallback) — оба ок. + const headers = Object.fromEntries( + [...r.headers.entries()].map(([k, v]) => [k.toLowerCase(), v]), + ) + for (const h of REQUIRED_HEADERS) { + expect(headers[h], `missing header on 404: ${h}`).toBeTruthy() + } + }) +})