security: enforce nonce based content policy

This commit is contained in:
ik
2026-09-13 16:38:47 +07:00
parent 1305cccfa5
commit 86ed3a966a
8 changed files with 68 additions and 11 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ FastAPI ─ PostgreSQL 17
Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся в Docker-сетях. Caddy завершает TLS и защищает административные страницы Basic Auth; административный API отдельно проверяет Bearer-токен в FastAPI. Basic не накладывается на API-запросы.
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, eval, wildcard и HTTP. Page scripts и scoped styles принудительно выпускаются отдельными same-origin `_astro`-ассетами; единственное временное inline-исключение остаётся для динамического JSON-LD и описано в [CSP inventory](docs/csp-inventory.md).
Production CSP ограничивает browser-запросы текущим доменом и отдельным files-доменом для изображений, запрещает plugins, frames, inline handlers и attributes, `unsafe-inline`, eval, wildcard и HTTP. Page scripts и scoped styles выпускаются отдельными same-origin `_astro`-ассетами; динамический JSON-LD получает новый криптографический nonce на каждый SSR-ответ. Caddy сохраняет эту policy и задаёт строгий fallback для служебных ответов. Локальный Compose отдельно разрешает только loopback API/MinIO; детали и проверка описаны в [CSP inventory](docs/csp-inventory.md).
Тема по умолчанию следует системному `prefers-color-scheme`, а переключатель в header позволяет выбрать системную, светлую или тёмную палитру. Выбор сохраняется в cookie и применяется Astro при SSR без localStorage-only flash и ослабления CSP; browser chrome синхронизируется парными `theme-color`. Публичные и административные поверхности, формы, таблицы, provenance/status-плашки и фирменная SVG/CSS-графика используют семантические light/dark-токены и базовый forced-colors layer; ограничения и оставшаяся визуальная приёмка описаны в [dark-theme.md](docs/dark-theme.md).
+38 -1
View File
@@ -39,6 +39,43 @@ const jsonLd = JSON.stringify({
"@context": "https://schema.org",
"@graph": jsonLdGraph,
}).replaceAll("<", "\\u003c");
const configuredFilesDomain = process.env.FILES_DOMAIN || "files.rf4spotter.ru";
const filesDomain = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(configuredFilesDomain)
? configuredFilesDomain
: "files.rf4spotter.ru";
const isLoopback = (hostname: string) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
const safeOrigin = (value: string | undefined, fallback: string) => {
try {
const url = new URL(value || fallback);
return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname)) ? url.origin : fallback;
} catch {
return fallback;
}
};
const apiOrigin = safeOrigin(import.meta.env.PUBLIC_API_URL, siteUrl);
const filesFallback = `https://${filesDomain}`;
const filesOrigin = (() => {
try {
const url = new URL(process.env.FILES_ORIGIN || filesFallback);
if ((url.protocol === "https:" && url.hostname === filesDomain) || (url.protocol === "http:" && isLoopback(url.hostname))) {
return url.origin;
}
} catch {
// Invalid deployment input falls back to the validated production hostname.
}
return filesFallback;
})();
const localOrigins = [apiOrigin, filesOrigin].filter((origin) => origin.startsWith("http://"));
const cspNonce = crypto.randomUUID().replaceAll("-", "");
Astro.response.headers.set("Content-Security-Policy", [
"default-src 'self'", "base-uri 'self'", "object-src 'none'", "frame-ancestors 'none'",
"form-action 'self'", `connect-src 'self'${apiOrigin === siteUrl ? "" : ` ${apiOrigin}`}`,
`img-src 'self' data: ${filesOrigin}`,
"font-src 'self'", "media-src 'self'", "manifest-src 'self'",
`script-src 'self' 'nonce-${cspNonce}'`, "script-src-attr 'none'",
"style-src 'self'", "style-src-attr 'none'",
...(localOrigins.length ? [] : ["upgrade-insecure-requests"]),
].join("; "));
---
<!doctype html>
<html lang="ru" data-theme={theme === "system" ? undefined : theme}>
@@ -70,7 +107,7 @@ const jsonLd = JSON.stringify({
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={socialImage} />
<meta name="twitter:image:alt" content="Лаймовый поплавок на тёмном озере с координатной сеткой" />
<script type="application/ld+json" set:html={jsonLd} is:inline />
<script type="application/ld+json" nonce={cspNonce} set:html={jsonLd} is:inline />
<title>{title}</title>
</head>
<body>
@@ -23,3 +23,20 @@ test("production bootstrap supports submission and moderation", async ({ page, r
expect(response.ok()).toBeTruthy();
expect(await response.text()).toContain(player);
});
test("SSR pages use a per-response CSP nonce for JSON-LD", async ({ page, request }) => {
for (const path of ["/", "/report", "/admin/"]) {
const response = await page.goto(path);
const policy = response?.headers()["content-security-policy"] ?? "";
expect(policy).toContain("script-src 'self' 'nonce-");
expect(policy).not.toContain("'unsafe-inline'");
expect(policy).toContain("style-src-attr 'none'");
const nonce = await page.locator('script[type="application/ld+json"]').evaluate((node: HTMLScriptElement) => node.nonce);
expect(nonce).toMatch(/^[a-f0-9]{32}$/);
expect(policy).toContain(`'nonce-${nonce}'`);
}
const image = await request.get("/og-rf4spotter.png");
expect(image.ok()).toBeTruthy();
expect(image.headers()["content-type"]).toContain("image/png");
});
+2
View File
@@ -182,6 +182,8 @@ services:
environment:
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
API_INTERNAL_URL: http://api:8000
FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN}
FILES_ORIGIN: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
depends_on:
api: {condition: service_healthy}
healthcheck:
+1
View File
@@ -80,6 +80,7 @@ services:
environment:
PUBLIC_API_URL: http://localhost:8000
API_INTERNAL_URL: http://api:8000
FILES_ORIGIN: http://localhost:9000
depends_on:
api:
condition: service_healthy
+1 -1
View File
@@ -52,7 +52,7 @@
Permissions-Policy "camera=(), microphone=(), geolocation=()"
X-Frame-Options "DENY"
Cross-Origin-Opener-Policy "same-origin"
Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self' data: https://{$FILES_DOMAIN}; font-src 'self'; media-src 'self'; manifest-src 'self'; script-src 'self' 'unsafe-inline'; script-src-attr 'none'; style-src 'self'; style-src-attr 'none'; upgrade-insecure-requests"
?Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; connect-src 'self'; img-src 'self' data: https://{$FILES_DOMAIN}; font-src 'self'; media-src 'self'; manifest-src 'self'; script-src 'self'; script-src-attr 'none'; style-src 'self'; style-src-attr 'none'; upgrade-insecure-requests"
}
}
+1 -1
View File
@@ -66,7 +66,7 @@
- [x] **Q11 · Декомпозиция API.** Catalog, activity/spots, records/community/status/import-history, submissions и весь admin API вынесены в отдельные `APIRouter`. `main.py` оставляет composition root, middleware, health/readiness и временные совместимые экспорты rate-limit для тестового контракта; URL и OpenAPI сохранены.
- [ ] **Q12 · Query-plan gate.** В рамках Q07 снять `EXPLAIN (ANALYZE, BUFFERS)` для activity, records, spot detail и public spot pages на реалистичном наборе данных. Существующие индексы миграции `0011_query_indexes` не дублировать; индекс с `fish_id`, SQL-агрегацию или materialized view добавлять только по измеренному плану и p95.
- [x] **Q13 · Production bootstrap в CI.** Отдельный workflow запускает `deploy/test-production-bootstrap.sh` вручную или раз в неделю, а не на каждом push. Вывод bootstrap всегда сохраняется 14 дней; при падении добавляются Compose status и Playwright diagnostics.
- [ ] **Q14 · Полная CSP — в работе.** Production ограничивает default/connect/form/font/media/manifest текущим доменом, изображения — self/data/`FILES_DOMAIN`, запрещает inline handlers, eval, wildcard и HTTP. Page scripts и scoped styles гарантированно выпускаются внешними `_astro`-ассетами; динамические шкалы переведены на CSS-классы. `style-src` теперь только `'self'`, `style-src-attr` и `script-src-attr``'none'`. Остаётся внедрить nonce/hash для динамического JSON-LD, убрать последнее `unsafe-inline` из `script-src` и выполнить bootstrap-проверку report/admin/OG/screenshots.
- [x] **Q14 · Полная CSP.** Каждый Astro SSR-ответ получает отдельный nonce для динамического JSON-LD и собственный строгий CSP; `FILES_DOMAIN` валидируется как hostname. Production запрещает inline handlers, `unsafe-inline`, eval, wildcard и HTTP; page scripts/styles остаются same-origin `_astro`-ассетами, style/script attributes запрещены. Caddy сохраняет upstream policy и использует строгий fallback для API. Bootstrap проверяет совпадение nonce на главной, report и admin и доступность OG; реальный signed screenshot проверяется после наполнения production MinIO.
- [x] **Q15 · Частичная деградация главной.** SSR независимо получает activity, community signals и оба справочника через settled-результаты. Отказ секции показывает собственный `StatePanel`, сохраняет остальные данные и HTTP 200 с `X-RF4-Partial`/`Cache-Control: no-store`; только отказ всех четырёх частей возвращает 503, `Retry-After` и noindex. Client-side loading и optimistic UI не добавлялись.
- [x] **Q16 · Контракт OpenAPI.** `apps/api/openapi.json` детерминированно генерируется из FastAPI; CI проверяет его актуальность после backend suite. Изменение artifact обязательно рассматривается вместе с реализацией, а ручное редактирование не используется.
- [x] **Q17 · Эксплуатационные документы.** Зафиксированы ADR по Astro/FastAPI/PostgreSQL, локальному cache, scheduler/cooldown и разделению PostgreSQL/MinIO. Incident runbook покрывает заполнение диска, отказ PostgreSQL/MinIO, зависшие импорты, ошибки миграций, компрометацию секретов и критерии закрытия без опасных reset/recreate операций.
+7 -7
View File
@@ -2,16 +2,16 @@
Проверено 13 сентября 2026 года. Production не требует произвольных внешних origin: browser API-запросы идут на текущий домен, изображения скриншотов — только на `FILES_DOMAIN`, шрифты и сборка Astro — локальные.
## Временные inline-зависимости
## Inline-данные
- динамический JSON-LD в `Layout.astro` остаётся единственным намеренно inline script;
- динамический JSON-LD в `Layout.astro` остаётся inline, но каждый SSR-ответ получает собственный криптографический nonce;
Vite собирает даже малые page scripts и scoped Astro styles в same-origin `_astro` assets (`assetsInlineLimit: 0`). Динамические шкалы используют дискретные CSS-классы вместо style attributes, поэтому `style-src` ограничен `'self'`, а `style-src-attr``'none'`. Временный `unsafe-inline` остаётся только для динамического JSON-LD в `script-src`. Inline event handlers запрещены `script-src-attr 'none'`; `unsafe-eval`, wildcard, HTTP и сторонние connect/script origin отсутствуют.
Vite собирает даже малые page scripts и scoped Astro styles в same-origin `_astro` assets (`assetsInlineLimit: 0`). Динамические шкалы используют дискретные CSS-классы вместо style attributes, поэтому `style-src` ограничен `'self'`, а `style-src-attr``'none'`. `unsafe-inline` и `unsafe-eval` отсутствуют в основном policy. Inline event handlers запрещены `script-src-attr 'none'`; wildcard и сторонние script origin не разрешены. Production `FILES_DOMAIN` проходит hostname-валидацию и используется через HTTPS. Отдельные HTTP-origin API и MinIO разрешаются только для явных loopback-адресов локального Compose; тогда `upgrade-insecure-requests` не выставляется.
## Путь к nonce/hash
## Реализация и проверка
1. Page scripts перенесены в собираемые клиентские модули; конфигурация формы передаётся через безопасный `data-state`. Нулевой inline limit запрещает Vite встраивать малые модули обратно в HTML.
2. JSON-LD либо хэшировать на уровне SSR-заголовка, либо выдавать nonce из web-runtime; статический nonce запрещён.
2. Web-runtime создаёт новый nonce для каждого ответа, добавляет его только JSON-LD и выпускает соответствующий CSP-заголовок. Статического nonce нет.
3. Scoped styles находятся в скомпилированных `_astro` assets, вычисляемые шкалы используют классы с шагом 5%; inline-разрешения для styles и attributes удалены.
4. Удалить последнее `unsafe-inline` из `script-src` после решения JSON-LD.
5. Проверить report, spots, admin, JSON-LD и signed screenshots в production bootstrap. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.
4. Caddy не перезаписывает CSP web-runtime и задаёт строгую политику без nonce только как fallback для API/служебных ответов.
5. Production bootstrap сверяет policy и nonce на главной, report и admin, а также доступность OG. Signed screenshots разрешены только с валидированного `FILES_DOMAIN`; проверка реальной подписи остаётся в сценарии наполненного production-хранилища. Не добавлять `blob:`, `*` или произвольные CDN для устранения ошибок.