Базовая безопасность FastAPI-приложения: заголовки, аутентификация и валидация ввода
Published: 2026-06-04
Когда владеешь всем стеком — приложением, инфраструктурой и деплоем — нет команды безопасности, которая поймает то, что пропустишь. Вот что я добавил в это FastAPI-приложение, чтобы закрыть очевидные дыры.
Middleware для security headers
Каждый ответ приложения получает фиксированный набор HTTP security headers — их добавляет один BaseHTTPMiddleware:
pythonclass SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "no-referrer-when-downgrade"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
response.headers["Content-Security-Policy"] = _CSP
response.headers["Server"] = ""
...
return response
Строка Server: "" убирает серверный баннер. В сочетании с --no-server-header в команде uvicorn ни один ответ не раскрывает, что стоит за Traefik.
Content Security Policy
CSP перечисляет источники явно, а не разрешает всё подряд:
default-src 'self';
script-src 'self' 'unsafe-inline' https://mc.yandex.ru https://cdn.jsdelivr.net;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https://mc.yandex.ru ...;
connect-src 'self' https://mc.yandex.ru wss://mc.yandex.ru;
font-src 'self';
object-src 'none';
base-uri 'self';
frame-ancestors 'none'
object-src 'none' блокирует Flash и плагины. frame-ancestors 'none' дублирует X-Frame-Options: DENY, но запас прочности здесь уместен. unsafe-inline для скриптов нужен потому, что marked.js встроен inline, а Яндекс.Метрика добавляет inline-обработчики. Nonce или хеш были бы чище, но лишняя сложность ради личного сайта не оправдана.
Кэширование статических ресурсов
Middleware также добавляет Cache-Control: public, max-age=31536000, immutable ко всему под /assets/. Скрипт деплоя вставляет timestamp сборки в service worker, который сбрасывает кэш app shell при деплое. Статические файлы получают заголовки вечного кэша только потому, что их URL версионированы по содержимому (через имя кэша SW).
HTTP Basic Auth
Страницы прокси и VPN содержат учётные данные, поэтому защищены HTTP Basic Auth:
pythondef require_auth(credentials: HTTPBasicCredentials = Depends(_security)):
if not settings.site_user:
return # auth отключён
ok_user = secrets.compare_digest(
credentials.username.encode(), settings.site_user.encode()
)
ok_pass = secrets.compare_digest(
credentials.password.encode(), settings.site_pass.encode()
)
if not (ok_user and ok_pass):
raise HTTPException(status_code=401, ...)
Вместо == используется secrets.compare_digest, чтобы предотвратить timing-атаки. Если SITE_USER не задан в .env, аутентификация полностью отключается — удобно для локальной разработки без необходимости вводить учётные данные.
Защита от path traversal
Роутеры blog и CV принимают пользовательские path-сегменты. Оба явно блокируют .. и /:
pythonif not slug or "/" in slug or ".." in slug:
raise HTTPException(status_code=400)
После этой проверки blog-роутер валидирует slug через slug.replace("-", "").replace("_", "").isalnum() — всё, что не является буквенно-цифровым с дефисами/подчёркиваниями, отклоняется до обращения к файловой системе. CV-роутер использует frozenset допустимых slug и отклоняет всё за его пределами.
Ни один роутер не строит путь через os.path.join(base, user_input) без предварительной валидации. Проверки происходят на уровне приложения, а не в надежде на то, что ОС заблокирует ../../etc/passwd.
Форма обратной связи
Форма обратной связи отправляет сообщения в Telegram через Bot API. Поля объявлены с ограничениями max_length в FastAPI Form(...), чтобы избежать неограниченного ввода. Сообщение отправляется как Markdown, так что пользовательский текст встраивается в Telegram-сообщение — не рендерится как HTML, не сохраняется в базе данных.
CSRF-токена нет, потому что эндпоинт не основан на сессиях. Единственное действие — отправить Telegram-сообщение; менять состояние от имени пользователя нечего.
Что не реализовано
- Нет ограничения частоты запросов к форме обратной связи (rate limiting через
slowapiбыл бы логичным дополнением). - Нет санитизации ввода, кроме ограничений длины — сообщение рендерит Telegram, так что XSS не угроза, но злоумышленник может внедрить форматирование Telegram Markdown.
- Нет audit log.
Для личного сайта этого достаточно. Модель угроз — в основном боты, сканирующие открытые прокси, и скрипткидди, а не целевые атаки.
Что делает каждый security header
| Заголовок | Защита |
|---|---|
X-Content-Type-Options: nosniff |
Запрещает браузерам угадывать MIME-тип вместо объявленного |
X-Frame-Options: DENY |
Блокирует встраивание страницы в iframe (clickjacking) |
Referrer-Policy: no-referrer-when-downgrade |
Ограничивает передачу Referer третьим сторонам |
Permissions-Policy |
Отключает браузерные API (камера, микрофон, геолокация) для этого origin |
X-XSS-Protection: 1; mode=block |
Устаревший XSS-фильтр в старых браузерах (современные используют CSP) |
Strict-Transport-Security |
Принудительный HTTPS на 1 год, включая поддомены |
Content-Security-Policy |
Белый список разрешённых источников ресурсов |
Нюансы HSTS
Strict-Transport-Security: max-age=31536000; includeSubDomains устанавливается на 1 год. Это значит:
- После первого получения заголовка браузер откажется от plain HTTP к домену на 1 год
includeSubDomainsраспространяется на все поддомены — используйте только если все поддомены обслуживают HTTPS
Чтобы отменить HSTS, нужно отдавать max-age=0 и ждать истечения кэшей HSTS у пользователей. Включайте его, только когда уверены.
Проверка заголовков
bash# Все заголовки ответа
curl -I https://antonnovikov.com
# Только security headers
curl -sI https://antonnovikov.com | grep -E "X-Frame|Content-Security|Strict-Transport|X-Content"
Или использовать securityheaders.com для полной оценки.