Безопасность в проде

Урок о практической, оборонительной защите боевого FastAPI: правильный CORS, заголовки безопасности, ограничение частоты и размера запроса, секреты через окружение.

Оборонительная безопасность — это защита собственного сервиса от типовых атак и злоупотреблений; здесь речь только о ней, а не о нападении на чужие системы.

Большинство реальных проблем с безопасностью API — не изощрённые взломы, а базовые недосмотры: CORS открыт всем, нет лимита на частоту запросов, секрет лежит в коде, тело запроса принимается без ограничения размера. Боты сканируют интернет круглосуточно и пробуют именно такие дыры по словарю. Хорошая новость: закрыть базовый список дёшево, и это отсекает подавляющую часть автоматических атак. Этот урок — практический чеклист оборонительной защиты FastAPI в проде.

Зачем это нужно на практике

Публичный API атакуют с первого дня — не из-за интереса к вам, а потому что сканеры пробуют всех подряд. Цель атак приземлённая: украсть данные, перебрать пароли, положить сервис нагрузкой, разослать спам. Базовая гигиена безопасности стоит несколько строк конфигурации, но даёт непропорционально большой эффект: вы перестаёте быть лёгкой добычей для автоматики.

CORS правильно

CORS (Cross-Origin Resource Sharing) управляет тем, каким сайтам браузер разрешит обращаться к вашему API из JavaScript. Распространённая опасная ошибка — открыть всё подряд: разрешить любой origin и при этом отдавать учётные данные. Правильно — перечислить конкретные доверенные домены.

from fastapi.middleware.cors import CORSMiddleware

# ОПАСНО: разрешено всё, да ещё с куками
# app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True)

# ПРАВИЛЬНО: только свои фронтенды, явный список методов и заголовков
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.example.com", "https://admin.example.com"],
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

Сочетание allow_origins=["*"] с allow_credentials=True — прямая дыра: по спецификации браузеры это и не должны позволять, поэтому такая настройка либо не сработает, либо толкнёт вас на ещё более опасные обходы. Перечисляйте домены явно. И помните: CORS защищает пользователя в браузере, но не заменяет авторизацию — запросы из curl или серверов он не ограничивает.

Security-заголовки

Браузер сам защищает пользователя, если сервер пришлёт правильные HTTP-заголовки. Их легко добавить общим middleware ко всем ответам.

@app.middleware("http")
async def security_headers(request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    response.headers["Content-Security-Policy"] = "default-src 'self'"
    return response
ЗаголовокЗачем
X-Content-Type-Options: nosniffЗапрещает браузеру «угадывать» MIME-тип ответа
X-Frame-Options: DENYЗащита от clickjacking через встраивание в iframe
Strict-Transport-SecurityЗаставляет браузер ходить только по HTTPS
Content-Security-PolicyОграничивает источники скриптов — заслон против XSS

Эти заголовки бесплатны и закрывают целый класс браузерных атак. Content-Security-Policy настраивайте под свой фронтенд аккуратно: слишком строгая политика может сломать загрузку легитимных ресурсов, поэтому её обкатывают и при необходимости расширяют осознанно.

Rate limiting

Ограничение частоты запросов (rate limiting) не даёт одному клиенту исчерпать ресурсы сервиса: перебирать пароли, спамить регистрациями, заваливать тяжёлый эндпоинт. Это базовая защита и от грубого DoS, и от злоупотреблений. В FastAPI её удобно подключить готовым пакетом (например, slowapi).

from slowapi import Limiter
from slowapi.util import get_remote_address

limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter

@app.post("/login")
@limiter.limit("5/minute")     # не больше 5 попыток входа в минуту с одного IP
async def login(request: Request, ...):
    ...

Строже всего лимитируйте чувствительные операции — вход, сброс пароля, отправку кода: для них перебор особенно опасен. Лимит привязывают к ключу (обычно IP, а для авторизованных — к пользователю). В кластере из нескольких воркеров счётчики держат в общем хранилище вроде Redis, иначе у каждого процесса будет свой лимит и общий порог «поплывёт». Помните, что за прокси настоящий IP клиента берётся из X-Forwarded-For — это требует доверять прокси-заголовкам (см. урок о деплое).

Секреты через env

Пароли БД, ключи API, секрет для подписи JWT нельзя держать в коде ни при каких условиях. Их место — переменные окружения, а на проде — хранилище секретов. FastAPI и Pydantic дают для этого BaseSettings: настройки читаются из окружения и валидируются при старте.

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    database_url: str
    jwt_secret: str
    debug: bool = False

    class Config:
        env_file = ".env"     # локально; в проде переменные задаёт окружение

settings = Settings()         # упадёт при старте, если обязательное не задано

Критично: файл .env добавляют в .gitignore до первого коммита, а в репозиторий кладут .env.example без значений. Однажды попавший в историю git секрет считается скомпрометированным навсегда — его недостаточно удалить, нужно отозвать и перевыпустить. Не печатайте секреты в логи и в ответы об ошибках. И обязательно держите debug=False в проде: подробные трейсбеки в ответах — подарок атакующему.

Ограничение размера запроса

Если принимать тело запроса без лимита, злоумышленник пришлёт гигантский payload и положит сервис по памяти — простой вид DoS. FastAPI сам жёсткого лимита по умолчанию не ставит, поэтому ограничение задают на входе. Надёжнее всего это делает обратный прокси, а в приложении можно отсекать по заголовку Content-Length.

from fastapi import Request, HTTPException

MAX_BODY = 1_000_000  # 1 МБ

@app.middleware("http")
async def limit_body_size(request: Request, call_next):
    cl = request.headers.get("content-length")
    if cl is not None and int(cl) > MAX_BODY:
        raise HTTPException(status_code=413, detail="Payload too large")
    return await call_next(request)

На уровне nginx тот же предел ставится директивой client_max_body_size 1m; — и это первая линия обороны, потому что прокси отсечёт огромный запрос ещё до приложения. Подбирайте лимит под реальные нужды: для обычного JSON хватит сотен килобайт, а загрузку файлов выносите на отдельный маршрут с потоковой обработкой и своим осознанным пределом. Код 413 Payload Too Large — стандартный ответ на превышение.

Как это работает под капотом

CORS реализован как обмен заголовками: на «предварительный» запрос OPTIONS сервер отвечает, какие origin, методы и заголовки разрешены, и уже браузер решает, пропускать ли основной запрос из JavaScript — то есть проверка живёт на стороне клиента, а сервер лишь декларирует политику. Security-заголовки работают так же: сервер объявляет правила, а применяет их браузер пользователя. Rate limiting опирается на счётчик по ключу: на каждый запрос middleware инкрементирует счётчик клиента в окне времени и при превышении порога возвращает 429 Too Many Requests — в кластере счётчик общий (Redis), иначе пороги у воркеров расходятся. BaseSettings читает переменные из окружения процесса при инициализации и валидирует типы, поэтому отсутствие обязательного секрета роняет приложение на старте, а не на первом запросе. Ограничение размера по Content-Length позволяет отказать ещё до чтения тела, экономя память.

Частые ошибки

  • allow_origins=["*"] вместе с allow_credentials=True. Опасная и невалидная по спецификации связка; перечисляйте домены явно.
  • Нет security-заголовков. Браузер не получает указаний и не защищает пользователя от XSS и clickjacking.
  • Нет rate limiting на входе и сбросе пароля. Открыт перебор и злоупотребления.
  • Секреты в коде или debug=True в проде. Утечка ключей и подробные трейсбеки в ответах — подсказка атакующему.
  • Тело запроса без лимита. Один большой payload кладёт сервис по памяти.
  • CORS как замена авторизации. Он защищает только браузер; запросы из curl и серверов он не ограничивает.

Итоги

  • Настраивайте CORS по белому списку доменов; не сочетайте allow_origins=["*"] с учётными данными.
  • Добавьте security-заголовки (nosniff, X-Frame-Options, HSTS, CSP) общим middleware.
  • Включите rate limiting, особенно строгий на чувствительных операциях; счётчики в кластере — в общем Redis.
  • Храните секреты в переменных окружения через BaseSettings; держите debug=False, утёкший секрет перевыпускайте.
  • Ограничивайте размер тела запроса (на прокси и в приложении), отдавая 413 на превышение.
Проверьте себя
1. Почему сочетание allow_origins=["*"] и allow_credentials=True в CORS считается опасным и проблемным?
AОно замедляет ответы сервера
BПо спецификации браузеры не должны разрешать отправку учётных данных при разрешённых любых origin — настройка либо не работает, либо толкает на опасные обходы
CОно отключает HTTPS
DОно ломает валидацию Pydantic
2. Зачем в продакшен-FastAPI нужен rate limiting, особенно на эндпоинтах вроде /login?
AЧтобы ускорить обработку запросов
BЧтобы один клиент не мог перебирать пароли, спамить или завалить сервис частыми запросами (защита от перебора и DoS)
CЧтобы автоматически кэшировать ответы
DЭто требование протокола HTTP
3. Что нужно сделать с секретом (например, JWT_SECRET), который случайно попал в историю git?
AДостаточно удалить строку в новом коммите
BНичего, если репозиторий приватный
CОтозвать и перевыпустить секрет, потому что он остаётся в истории git и считается скомпрометированным
DПереименовать переменную окружения
4. Почему стоит ограничивать размер тела запроса (например, отдавать 413 при превышении лимита)?
AЧтобы ускорить парсинг JSON в браузере
BЧтобы злоумышленник не положил сервис гигантским телом запроса (простой DoS), исчерпав память
CЧтобы логи занимали меньше места
DЭто требование стандарта JSON