Хардненинг по итогам пен-теста #25 (#56–#62) #63

Merged
NotBigGhost merged 7 commits from issue-56-62-hardening into dev 2026-09-13 18:38:11 +03:00
3 changed files with 49 additions and 4 deletions
Showing only changes of commit ce7fbad58e - Show all commits
+7 -3
View File
@@ -151,12 +151,16 @@ async def _lifespan(_app: FastAPI):
def create_app() -> FastAPI: def create_app() -> FastAPI:
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
# незачем облегчать разведку поверхности API анонимам (#61).
docs_enabled = not settings.is_production
app = FastAPI( app = FastAPI(
title="Forbidden Stars API", title="Forbidden Stars API",
version="0.1.0", version="0.1.0",
openapi_url="/api/openapi.json", openapi_url="/api/openapi.json" if docs_enabled else None,
docs_url="/api/docs", docs_url="/api/docs" if docs_enabled else None,
redoc_url="/api/redoc", redoc_url="/api/redoc" if docs_enabled else None,
lifespan=_lifespan, lifespan=_lifespan,
) )
+22
View File
@@ -0,0 +1,22 @@
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6)."""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.core import config
from app.main import create_app
def test_openapi_open_in_development(client: TestClient):
# Тесты идут в development (conftest) — схема доступна: нужна для `npm run gen:api`.
assert client.get("/api/openapi.json").status_code == 200
assert client.get("/api/docs").status_code == 200
def test_openapi_closed_in_production(monkeypatch):
monkeypatch.setattr(config.settings, "app_env", "production")
prod_app = create_app()
c = TestClient(prod_app)
assert c.get("/api/openapi.json").status_code == 404
assert c.get("/api/docs").status_code == 404
assert c.get("/api/redoc").status_code == 404
+20 -1
View File
@@ -18,8 +18,27 @@
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит. # иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
# Файл заглушки — deploy/vps/maintenance.html. # Файл заглушки — deploy/vps/maintenance.html.
# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима. # Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
(edge) { (edge) {
# Security-заголовки (#61). HSTS — принудительный HTTPS на год с поддоменами; nosniff —
# запрет MIME-sniffing; frame DENY — защита от кликджекинга (наши страницы нельзя встроить
# в чужой iframe); Referrer/Permissions — минимизация утечек. Server скрываем, чтобы не
# светить используемый прокси.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
-Server
}
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая
# политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с
# telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать
# после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61).
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый # HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно. # SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно. # Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.