Базовая безопасность 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 для полной оценки.