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() + } + }) +})