Безопасность в проде
Урок о практической, оборонительной защите боевого 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на превышение.