Безопасное проектирование API

Урок о том, почему безопасность API закладывается на этапе проектирования, а не прикручивается потом.

API (Application Programming Interface) — программный контракт, по которому одни системы вызывают другие; для атакующего это самая большая и подробно задокументированная поверхность атаки приложения.

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

Зачем это знать защитнику

Согласно отраслевым рейтингам (OWASP API Security Top 10) большинство реальных инцидентов с API — это не экзотические эксплойты, а провалы авторизации: эндпоинт проверяет, что пользователь вошёл в систему, но не проверяет, что именно этот ресурс ему принадлежит. Защитник, который понимает, как проектируется безопасный API, закрывает целый класс уязвимостей до того, как они попадут в прод.

Аутентификация и авторизация на каждом эндпоинте

Важно различать два понятия. Аутентификация отвечает на вопрос «кто ты?» (валидный токен, сессия, ключ). Авторизация отвечает на вопрос «что тебе можно?» (имеешь ли ты право на этот конкретный ресурс и действие). Классическая ошибка — реализовать первое и забыть второе.

BOLA: сломанная авторизация на уровне объекта

Представьте эндпоинт, который отдаёт заказ по его идентификатору. Уязвимый код проверяет только наличие токена:

@app.get("/orders/{order_id}")
def get_order(order_id: int, user=Depends(current_user)):
    # есть валидный токен — и всё. Чей это заказ, не проверяется.
    return db.orders.find(order_id)

Любой аутентифицированный пользователь, меняя order_id в адресе (1, 2, 3...), читает чужие заказы. Это уязвимость BOLA (Broken Object Level Authorization), она же IDOR. Защита — проверять владение объектом на сервере:

@app.get("/orders/{order_id}")
def get_order(order_id: int, user=Depends(current_user)):
    order = db.orders.find(order_id)
    if order is None or order.owner_id != user.id:
        # одинаковый ответ и для «нет», и для «чужое» — не подсказываем атакующему
        raise HTTPException(status_code=404)
    return order

Ключевая идея: сервер никогда не доверяет идентификатору из запроса как доказательству прав. Принадлежность ресурса вычисляется на бэкенде из аутентифицированной личности, а не из того, что прислал клиент.

Строгая валидация ввода

Второй столп — относиться ко всем входным данным как к недоверенным. Это не только тело запроса, но и query-параметры, заголовки, путь. Подход «allow-list» (разрешаем только то, что ожидаем) надёжнее, чем «deny-list» (пытаемся перечислить всё плохое). Описывайте контракт схемой и валидируйте по ней автоматически:

from pydantic import BaseModel, Field, EmailStr

class CreateUser(BaseModel):
    email: EmailStr
    age: int = Field(ge=0, le=150)
    role: str = Field(pattern="^(reader|author)$")  # фикс. набор, не любой текст

Схема отсекает мусор на границе приложения: лишние поля, неверные типы, выход за диапазон. Отдельно помните про mass assignment: не привязывайте тело запроса напрямую к модели БД, иначе клиент пришлёт {"role": "admin", "is_verified": true} и повысит себе права. Принимайте только явно перечисленные поля.

Версионирование как механизм безопасности

Версионирование (/v1/, /v2/) обычно считают темой совместимости, но у него есть оборонительная роль. Когда вы находите небезопасный дефолт в старой версии, вы не можете молча сломать клиентов — зато можете выпустить /v2/ с исправленным поведением и спланированно вывести старую из эксплуатации (deprecation). Без версий любое изменение контракта рискует либо оставить дыру, либо положить интеграции.

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

Stateless-аутентификация в API часто построена на токенах. JWT (JSON Web Token) несёт подписанные утверждения о пользователе; сервер проверяет подпись и не ходит в БД за сессией. Но именно здесь кроются ловушки конфигурации:

  • сервер обязан фиксировать алгоритм подписи и проверять aud/iss/exp; иначе возможны атаки на подмену алгоритма;
  • в полезной нагрузке токена не должно быть секретов — она лишь закодирована (base64url), а не зашифрована, и читается кем угодно;
  • права (scope/roles) в токене — это заявка, итоговое решение всё равно принимает эндпоинт.

Под «безопасными дефолтами» понимают принцип fail-closed: при сомнении доступ запрещается, а не разрешается. Новый эндпоинт по умолчанию закрыт; неизвестный метод/маршрут отдаёт 404/405, а не «случайно» обрабатывается. Это противоположность опасному паттерну, когда забытая проверка означает открытый доступ.

Как защититься

  • Авторизуйте каждый эндпоинт на уровне объекта и действия; пишите тесты «чужой пользователь получает 403/404».
  • Применяйте принцип наименьших привилегий: токен и роль дают ровно столько, сколько нужно для задачи.
  • Валидируйте вход по схеме (allow-list), отключайте mass assignment, ограничивайте размер тела запроса.
  • Не раскрывайте лишнее в ответах и ошибках: единообразные коды, без стек-трейсов и внутренних идентификаторов наружу.
  • Версионируйте API и держите план вывода небезопасных версий из эксплуатации.
  • Включайте HTTPS, CORS по allow-list доменов, security-заголовки; логируйте отказы авторизации для мониторинга.

Проверять всё это можно только на своих системах или в разрешённой лаборатории (DVWA, OWASP Juice Shop, TryHackMe). Несанкционированный доступ к чужим API — нарушение закона (в РФ — ст. 272 УК РФ).

Итоги

  • API — крупная и хорошо документированная поверхность атаки; «защиты через неочевидность» нет.
  • Главный риск — авторизация: проверяйте владение ресурсом на сервере, а не доверяйте id из запроса.
  • Валидируйте ввод по allow-list-схеме и не привязывайте тело запроса к модели напрямую.
  • Версионирование и безопасные дефолты (fail-closed) — часть оборонительной архитектуры.
Проверьте себя
1. Эндпоинт GET /orders/{id} проверяет только валидность токена и возвращает заказ по id из URL. Какая это уязвимость?
ASQL-инъекция
BBOLA / IDOR — сломанная авторизация на уровне объекта
CCross-Site Scripting (XSS)
DПереполнение буфера
2. Что означает принцип безопасных дефолтов «fail-closed» для нового эндпоинта?
AПо умолчанию доступ открыт, закрываем по мере надобности
BПри сомнении или забытой проверке доступ запрещается, а не разрешается
CВсе ошибки возвращают код 200
DВерсионирование отключено для скорости