From 86ed3a966af2b3a7028b2f47d244d05391855faf Mon Sep 17 00:00:00 2001 From: IK Date: Sun, 13 Sep 2026 16:38:47 +0700 Subject: [PATCH] security: enforce nonce based content policy --- README.md | 2 +- apps/web/src/layouts/Layout.astro | 39 ++++++++++++++++++++- apps/web/tests/production-bootstrap.spec.ts | 17 +++++++++ compose.production.yaml | 2 ++ compose.yaml | 1 + deploy/Caddyfile | 2 +- docs/ROADMAP.md | 2 +- docs/csp-inventory.md | 14 ++++---- 8 files changed, 68 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 3bb257c..27d6967 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/apps/web/src/layouts/Layout.astro b/apps/web/src/layouts/Layout.astro index 480bb74..ac62652 100644 --- a/apps/web/src/layouts/Layout.astro +++ b/apps/web/src/layouts/Layout.astro @@ -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("; ")); --- @@ -70,7 +107,7 @@ const jsonLd = JSON.stringify({ -