fix(security): add HSTS header on stage + integration test
Some checks failed
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled

Найдено в 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 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-09 03:35:38 +05:00
parent ed140cb819
commit a80471d0f9
2 changed files with 55 additions and 0 deletions

View file

@ -14,6 +14,14 @@ server {
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always;
add_header X-Permitted-Cross-Domain-Policies "none" 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 # Long-running admin imports (MoySklad etc.) read from upstream for tens of
# minutes. Bump timeouts only on that path so normal API stays snappy. # minutes. Bump timeouts only on that path so normal API stays snappy.
location /api/admin/import/ { location /api/admin/import/ {

View file

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