Безопасность приложений Безопасность API: rate limiting, IDOR, массовое присвоение, валидация
0%

Безопасность API: rate limiting, IDOR, массовое присвоение, валидация

Безопасность API: rate limiting, IDOR, массовое присвоение, валидация

У веб-страницы есть посредник — браузер, который что-то не покажет и куда-то не даст нажать. У API посредника нет: клиент API — программа, и она отправит ровно то, что захочет её автор: любой путь, любой метод, любые поля, любой идентификатор, миллион раз в секунду. Всё, что «спрятано» в мобильном приложении или SPA, лежит у пользователя на устройстве и разбирается за вечер; полагаться на это — CWE-602, перенос проверки на клиента (https://cwe.mitre.org/data/definitions/602.html).

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

Четыре независимых измерения поверхности API: маршрут, объект, свойство и расход

Классификация не выдумана: она почти дословно совпадает со структурой OWASP API Security Top 10 2023 (https://owasp.org/API-Security/editions/2023/en/0x11-t10/) — отдельного списка, который существует потому, что общий OWASP Top 10 плохо описывает специфику машинных интерфейсов. Разбор идёт по знакомой схеме: уязвимый код → почему это работает → как чинить → как убедиться, что починено.

Рамка: мы защищаем, а не атакуем

Всё дальше написано с позиции защищающейся стороны. Примеры запросов — проверки на собственном стенде с двумя тестовыми аккаунтами, а не эксплойты. Правило, из которого нет исключений: проверять можно только системы, которыми вы владеете сами, или те, на которые есть письменное разрешение владельца — с зафиксированным перечнем хостов и эндпоинтов, временным окном, ограничением на нагрузочные сценарии и контактом для эскалации. Устного «мне разрешил тимлид» недостаточно; перебор идентификаторов на чужом API — не исследование. Процедурная сторона: OWASP Web Security Testing Guide (https://owasp.org/www-project-web-security-testing-guide/) и NIST SP 800-115 (https://csrc.nist.gov/pubs/sp/800/115/final).

Слои: кто и что физически может проверить

Прежде чем чинить классы уязвимостей, нужно понять, почему нельзя «вынести безопасность на шлюз» — вопрос из каждой второй архитектурной дискуссии.

Путь запроса через край сети, шлюз, сервис и хранилище: что каждый слой видит и чего решить не может

Ключ к схеме — правая колонка. Шлюз проверяет подпись токена, но не знает, чей счёт номер 42: данных о владении у него нет. Край сети видит адрес источника, но за одним адресом сидит корпоративный NAT на три тысячи человек. Хранилище отсечёт чужого арендатора политикой RLS, но не отличит ошибку в коде от намеренной атаки. Каждый слой закрывает то, что видит; эшелонирование здесь не лозунг, а следствие разной наблюдаемости.

API1: BOLA, он же IDOR

Broken Object Level Authorization (CWE-639, авторизация в обход ключа, управляемого пользователем, https://cwe.mitre.org/data/definitions/639.html). Историческое имя — IDOR. Первое место в списке OWASP не случайно: ошибка тривиальна, находится вручную за минуту и сразу даёт доступ ко всей базе.

# УЯЗВИМО. FastAPI: идентификатор из пути напрямую уходит в выборку.
@router.get("/api/v1/invoices/{invoice_id}")
async def get_invoice(invoice_id: UUID, user: User = Depends(current_user)):
    invoice = await db.invoices.get(invoice_id)   # владелец не проверяется
    if invoice is None:
        raise HTTPException(status_code=404)
    return InvoiceOut.model_validate(invoice)

Аутентификация здесь есть — токен валиден, current_user отработал. Не хватает второго вопроса: этот ли счёт принадлежит этому пользователю. Тот же дефект в других обличьях: ?account_id= в query, tenant в теле запроса, путь /v1/users/{id}/orders, где проверяется users, но не orders.

Почему это работает. Идентификатор в запросе — аргумент, а не полномочие. Приложение читает его как «какой объект вернуть», разработчик — как «свой объект пользователя», потому что в интерфейсе ссылку на чужой счёт взять неоткуда. Но интерфейс в разговоре не участвует: HTTP-клиент подставит любое значение.

Отдельно стоит убить надежду на «неугадываемые» идентификаторы. Переход с автоинкремента на UUIDv4 полезен: он ломает тривиальный перебор id=1,2,3… и не выдаёт метрики бизнеса (по номеру заказа виден оборот). Но контролем доступа он не является: идентификаторы утекают в письмах, реферерах, логах, экспортах, чужих скриншотах и ваших же ответах API. Секрет, который вы сами рассылаете, — не секрет; это CWE-340, предсказуемость как единственная защита.

Как чинить. Принцип: невозможность написать запрос без области видимости. Не «не забыть проверить», а «не суметь не проверить» — потому что забывают всегда, особенно в четырёхсотом обработчике.

# ПРАВИЛЬНО. Область видимости — часть контракта репозитория, а не забота обработчика.
class InvoiceRepo:
    def __init__(self, session: AsyncSession, ctx: AuthContext) -> None:
        self._s, self._ctx = session, ctx          # арендатор зафиксирован конструктором

    async def get(self, invoice_id: UUID) -> Invoice | None:
        stmt = (select(Invoice)
                .where(Invoice.id == invoice_id)
                .where(Invoice.tenant_id == self._ctx.tenant_id))   # всегда, для любого запроса
        return (await self._s.execute(stmt)).scalar_one_or_none()

@router.get("/api/v1/invoices/{invoice_id}", response_model=InvoiceOut)
async def get_invoice(invoice_id: UUID, repo: InvoiceRepo = Depends(invoice_repo)):
    invoice = await repo.get(invoice_id)
    if invoice is None:
        # Чужой объект и несуществующий неотличимы снаружи — см. раздел про ошибки.
        raise HTTPException(status_code=404, detail="Invoice not found")
    return invoice

Для прав тоньше арендатора (роль внутри организации, доступ по расшаренной ссылке, проектные права) добавляется единая функция решения authorize(ctx, action, resource), которую вызывают все обработчики и которая пишет в аудит и субъект, и объект, и причину отказа. Ключевое для API: решение принимается после загрузки объекта (иначе не по чему решать) и до любого действия с ним, а идентификатор арендатора берётся исключительно из проверенного токена — никогда из заголовка, query или тела. Модели прав (RBAC, ABAC, ReBAC) и multi-tenancy разобраны в статье про авторизацию.

Последний рубеж — на стороне СУБД. PostgreSQL RLS (https://www.postgresql.org/docs/current/ddl-rowsecurity.html) превращает забытый WHERE из утечки в пустой результат:

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;   -- политика действует и на владельца таблицы

CREATE POLICY invoices_tenant_isolation ON invoices
    USING      (tenant_id = current_setting('app.tenant_id')::uuid)
    WITH CHECK (tenant_id = current_setting('app.tenant_id')::uuid);

app.tenant_id выставляется в начале транзакции через SET LOCAL из данных токена. Тонкость, на которой обжигаются: пул переиспользует соединения, поэтому нужен именно SET LOCAL (живёт до конца транзакции), а приложение должно ходить под ролью без BYPASSRLS.

Как проверить. Единичный тест «Боб не читает счёт Алисы» не масштабируется: BOLA появляется в новом эндпоинте, а не в старом. Работает матричный тест, обходящий таблицу маршрутов.

# Для каждого маршрута с параметром пути берём объект ЧУЖОГО арендатора и требуем 404/403.
# Новый маршрут попадает в матрицу автоматически — в этом весь смысл.
@pytest.mark.parametrize("route", object_routes(app))
def test_foreign_object_is_not_reachable(client, route, alice_ctx, bob_ctx, factories):
    obj = factories.create_for(route.resource, tenant=alice_ctx.tenant_id)
    url = route.path.format(**{route.param: obj.id})
    resp = client.request(route.method, url, headers=bob_ctx.auth_headers, json=route.sample_body)
    assert resp.status_code in {403, 404}, f"{route.method} {url}: доступен чужой объект"
    assert str(obj.id) not in resp.text          # и в теле ошибки объекта быть не должно

Дополняют картину правило Semgrep на выборки без фильтра по арендатору и алерт на всплеск 403/404 с одного принципала по разным идентификаторам — перебор выглядит именно так. DAST-сканеры BOLA почти не находят: сканер не знает, какой объект чей. Ищут матричными тестами и на ревью.

API3: массовое присвоение и утечка полей

Broken Object Property Level Authorization объединяет две зеркальные ошибки: на входе пользователь пишет поля, которые ему не принадлежат (mass assignment, CWE-915), на выходе получает поля, которых не должен видеть (CWE-213).

// УЯЗВИМО. Express + Mongoose: тело запроса целиком накатывается на документ.
app.patch('/api/v1/users/me', requireAuth, async (req, res) => {
  const user = await User.findById(req.session.userId);
  Object.assign(user, req.body);        // сюда приедет role, emailVerified, balance…
  await user.save();
  res.json(user);                       // …а отсюда уедет passwordHash и внутренние флаги
});

Тот же дефект: User(**payload) в Python, биндинг модели в ASP.NET без [Bind], permit! вместо permit(:name) в Rails, mapper.map(dto, entity) без явного профиля.

Почему это работает. Биндинг связывает имена внешних ключей с именами внутренних полей, а внутренняя модель почти всегда шире публичного контракта: в ней живут role, is_verified, tenant_id, credit_limit, deleted_at. Разработчик описывал форму, пользователь прислал JSON, и единственная граница между «поля формы» и «поля модели» существовала в голове автора кода. Атака не требует ничего, кроме внимательного чтения ответа того же API: что сервер возвращает, то он обычно и принимает. Отдельный подвид — вложенные объекты: {"items": [{"price": 0}]} или {"customer": {"discount": 99}}; плоская проверка верхнего уровня их пропускает.

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

// ПРАВИЛЬНО. Zod: .strict() отвергает неизвестные ключи вместо тихого игнорирования.
const UserSelfPatch = z.object({
  displayName: z.string().trim().min(1).max(80).optional(),
  locale:      z.enum(['ru', 'en']).optional(),
  timezone:    z.string().max(64).optional(),
}).strict();

const toUserResponse = (u: UserDoc) =>            // проекция, а не сущность
  ({ id: u.id, displayName: u.displayName, locale: u.locale, createdAt: u.createdAt });

app.patch('/api/v1/users/me', requireAuth, async (req, res) => {
  const parsed = UserSelfPatch.safeParse(req.body);
  if (!parsed.success) return res.status(422).json(problem(parsed.error));
  const user = await User.findByIdAndUpdate(
    req.session.userId,
    { $set: parsed.data },                        // только разобранные поля, не req.body
    { new: true, runValidators: true },
  );
  res.json(toUserResponse(user));
});

Python-эквивалент — три модели вместо одной сущности:

class UserSelfPatch(BaseModel):
    model_config = ConfigDict(extra="forbid")     # неизвестное поле → 422, а не тишина
    display_name: str | None = Field(default=None, min_length=1, max_length=80)
    locale: Literal["ru", "en"] | None = None

class UserOut(BaseModel):                          # белый список полей ответа
    id: UUID
    display_name: str
    created_at: datetime

@router.patch("/api/v1/users/me", response_model=UserOut)
async def patch_me(body: UserSelfPatch, repo: UserRepo = Depends(user_repo)):
    # exclude_unset даёт корректную семантику PATCH: «поле не прислали» ≠ «прислали null»
    return await repo.update_self(body.model_dump(exclude_unset=True))

Нюансы, которые ломают даже правильную на вид схему:

  • extra="forbid" против extra="ignore": второе безопасно, но молча съедает опечатки клиента; первое ловит и ошибки интеграции, и подбор имён полей. Для публичных API, которым нужна forward compatibility, компромисс — игнорировать неизвестные, но логировать их.
  • response_model обязателен для каждого маршрута. Возврат ORM-объекта «как есть» — типовая причина утечки: добавили в таблицу колонку internal_score, и она поехала клиентам.
  • Разные схемы для разных ролей. Администратору можно PATCH {"role": …}, но это отдельный маршрут /api/v1/admin/users/{id} с отдельной схемой и проверкой прав, а не флаг внутри общего обработчика.
  • Вложенность описывается целиком: items — не list[dict], а list[OrderItemIn] со своим строгим набором полей.
  • Маскирование вместо удаления. Если поле нужно клиенту частично, отдавайте **** 4242, а не полный номер с фильтрацией на фронтенде.

Как проверить:

@pytest.mark.parametrize("field,value", [
    ("role", "admin"), ("tenant_id", str(uuid4())), ("is_verified", True), ("balance", 10_000),
])
def test_privileged_fields_are_not_assignable(client, alice, field, value):
    resp = client.patch("/api/v1/users/me", json={field: value}, headers=alice.auth)
    assert resp.status_code == 422                       # strict-схема отвергла ключ
    assert getattr(reload(alice.user), field) != value   # и состояние не изменилось

def test_response_has_no_extra_fields(client, alice):
    allowed = {"id", "display_name", "created_at"}
    body = client.get("/api/v1/users/me", headers=alice.auth).json()
    assert set(body) == allowed, f"лишние поля в ответе: {set(body) - allowed}"

Второй тест ценнее, чем кажется: он падает в тот день, когда кто-то добавит поле в модель, — ровно в тот день, когда об утечке нужно узнать.

Валидация: три уровня, которые часто считают одним

Валидация в API — не «проверить, что email похож на email». Это три разные проверки в строгом порядке, потому что каждая следующая опирается на результат предыдущей.

Порядок не декоративный: разбирать JSON до проверки размера — значит принять на себя аллокацию, а проверять права до валидации — принимать решения по неразобранным данным.

Позитивная модель. Валидация описывает, что разрешено, а не что запрещено. Чёрные списки («отрезать <script>», «запретить ../») проигрывают всегда: множество плохих значений бесконечно, множество хороших конечно и известно. Тот же принцип лежит в основе защиты от инъекций. И сразу важная оговорка: валидация не заменяет экранирование и параметризацию — имя О'Коннор валидно и обязано работать.

MAX_DEPTH, MAX_NODES = 12, 20_000

def assert_json_shape(node, depth: int = 0, budget: list[int] | None = None) -> None:
    """Защита от «JSON-бомбы»: глубокая вложенность и гигантские коллекции.
    Время O(n) по числу узлов, память O(depth) на стек рекурсии."""
    budget = budget if budget is not None else [MAX_NODES]
    budget[0] -= 1
    if budget[0] < 0:
        raise ValidationError("документ слишком большой")
    if depth > MAX_DEPTH:
        raise ValidationError("превышена глубина вложенности")
    if isinstance(node, dict):
        for value in node.values():
            assert_json_shape(value, depth + 1, budget)
    elif isinstance(node, list):
        for value in node:
            assert_json_shape(value, depth + 1, budget)

Пределы, о которых забывают:

  • Размер тела ограничивается на краю (client_max_body_size в nginx) и в приложении: край можно обойти внутренним маршрутом.
  • Глубина и число узлов — иначе [[[[[…]]]]] на 200 килобайт даёт экспоненциальную работу парсера или рекурсию до переполнения стека (CWE-674).
  • Длины строк и размеры коллекций"tags" на миллион элементов уходит в базу как миллион вставок.
  • Пагинация: limit со значением по умолчанию и жёстким максимумом. ?limit=1000000 без потолка — отказ в обслуживании, оформленный как обычный запрос.
  • ReDoS: ^(\w+\s?)+$ на строке из сорока символов вешает поток на минуты (CWE-1333, https://cwe.mitre.org/data/definitions/1333.html). Лечится ограничением длины входа до применения выражения, отказом от вложенных квантификаторов и движком без бэктрекинга (RE2).
  • Путаница типов: JSON различает 1, "1" и true, а NoSQL-драйвер может принять {"$ne": null} вместо строки — схема с явными типами закрывает это.
  • Content-Type принимается только объявленный; разбор тела «по угадыванию» — источник неожиданностей.

Контракт как источник истины. Схема должна быть одна: OpenAPI 3.1 (https://spec.openapis.org/oas/latest.html), совместимая с JSON Schema (https://json-schema.org/). Из неё генерируются серверные модели, клиенты и тесты. Валидация по спецификации на шлюзе — хорошая вторая линия, но не первая: шлюз не знает семантики, а внутренние вызовы его минуют. Проверять удобно property-based тестами по самой спецификации: Schemathesis (https://schemathesis.readthedocs.io/) генерирует запросы из OpenAPI и ловит 500-е, несоответствие схеме ответа и падения на граничных значениях. Контрактное тестирование разобрано в статье про тестирование API.

API4: ограничение частоты и расхода ресурсов

Отдельный класс, потому что здесь нет «неправильного» запроса: каждый легитимен, проблема в количестве и стоимости. CWE-770 (ресурсы без ограничений, https://cwe.mitre.org/data/definitions/770.html) и CWE-799 (контроль частоты взаимодействия). Зачем это нужно, кроме доступности:

  1. Подбор паролей, кодов из SMS, токенов восстановления: без лимита шестизначный код перебирается за секунды — см. аутентификацию.
  2. Массовое извлечение данных: каждый ответ легален, но за ночь выгружается вся база контрагентов. Формально не взлом, фактически утечка.
  3. Прямые деньги: SMS, письма, платные вызовы внешних сервисов, токены языковых моделей. Незалимиченный POST /v1/otp — это счёт, который выставят вам.
  4. Справедливость: один клиент с ретраями без джиттера кладёт сервис остальным.

Выбор алгоритма

Алгоритм Состояние на ключ Поведение Когда брать
Fixed window счётчик + окно, O(1) на стыке окон пропускает до 2× лимита грубая отсечка на краю
Sliding window log метки времени, O(k) от лимита точно, дорого по памяти малые лимиты, критичные операции
Sliding window counter 2 счётчика, O(1) интерполяция, погрешность единицы процентов публичные квоты
Token bucket токены + метка времени, O(1) допускает управляемый всплеск значение по умолчанию для API
Leaky bucket (очередь) очередь, O(k) сглаживает выход, добавляет задержку защита медленного бэкенда
GCRA одна метка времени, O(1) эквивалент leaky bucket без очереди нужны и точность, и память
Лимит конкурентности счётчик активных, O(1) ограничивает не темп, а одновременность тяжёлые отчёты и выгрузки

Token bucket — разумное значение по умолчанию: параметры (rate, burst) объяснимы продукту, состояние константно, всплеск после простоя разрешён явно.

Реализация: атомарность обязательна

Наивный GET + вычисление + SET — гонка: при конкурентных запросах лимит превышается кратно числу реплик. Поэтому логика уезжает в Lua-скрипт Redis, который выполняется целиком, без чередования с другими командами.

TOKEN_BUCKET = """
local key   = KEYS[1]
local rate  = tonumber(ARGV[1])   -- токенов в секунду
local burst = tonumber(ARGV[2])   -- ёмкость ведра (максимальный всплеск)
local cost  = tonumber(ARGV[3])   -- стоимость текущего запроса

-- время берём у сервера Redis: часы клиентов расходятся, часы Redis общие
local t   = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1000000.0

local st     = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(st[1])
local ts     = tonumber(st[2])
if tokens == nil then tokens = burst; ts = now end

tokens = math.min(burst, tokens + math.max(0, now - ts) * rate)   -- долив по времени

local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end

redis.call('HSET', key, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', key, math.ceil((burst / rate) * 1000) + 1000)  -- живёт до восстановления

local retry_after = 0
if allowed == 0 then retry_after = math.ceil((cost - tokens) / rate) end
return { allowed, math.floor(tokens), retry_after }
"""

class RateLimiter:
    def __init__(self, redis: Redis) -> None:
        self._script = redis.register_script(TOKEN_BUCKET)     # EVALSHA, без пересылки текста

    async def check(self, key: str, rate: float, burst: int, cost: int = 1) -> Verdict:
        allowed, remaining, retry_after = await self._script(keys=[f"rl:{key}"],
                                                             args=[rate, burst, cost])
        return Verdict(bool(allowed), int(remaining), int(retry_after))

Сложность: O(1) по времени и O(1) по памяти на ключ — в отличие от sliding window log, где память растёт с лимитом. Один сетевой round-trip на запрос; при жёстких требованиях к задержке ставят двухуровневую схему: локальное ведро в процессе плюс асинхронная сверка с общим состоянием.

Ключ лимита — половина задачи

  • По адресу источника. Дёшево и работает против примитивной автоматики, но за одним адресом сидит офис или мобильный оператор, а атакующий берёт пул адресов. Для IPv6 агрегируйте минимум по /64, иначе у клиента практически бесконечный запас ключей.
  • Доверие X-Forwarded-For — самостоятельная уязвимость. Если приложение берёт первый элемент заголовка, клиент подделывает ключ лимита одной строкой и обходит ограничение полностью. Брать можно только адрес, добавленный вашим доверенным прокси: set_real_ip_from в nginx, trust proxy с числом хопов в Express, KnownProxies в ASP.NET. Современная альтернатива — заголовок Forwarded из RFC 7239.
  • По принципалу (пользователь, арендатор, клиентский ключ) — основной ключ для аутентифицированных маршрутов.
  • Комбинация. Для входа и восстановления пароля нужны оба лимита сразу: по источнику (перебор паролей к одному аккаунту) и по учётной записи (распределённый перебор одного пароля ко многим аккаунтам, credential stuffing).
  • По стоимости, а не по числу. Поиск с агрегацией и GET /health не равны: cost=1 для чтения, cost=20 для отчёта, cost=50 для экспорта. В GraphQL то же делается расчётом сложности запроса.

Поведение при недоступности хранилища лимитов выбирается осознанно: для входа, отправки кодов и платежей — fail-closed (или жёсткий локальный лимит в процессе), для обычного чтения — fail-open с локальным ведром и алертом. Молчаливое «Redis упал, лимитов нет» — типичный способ потерять защиту ровно в момент атаки.

Ответ клиенту

@app.middleware("http")
async def rate_limit_mw(request: Request, call_next):
    key, rate, burst, cost = policy_for(request)          # маршрут + принципал → политика
    v = await limiter.check(key, rate, burst, cost)
    if not v.allowed:
        return JSONResponse(
            status_code=429,
            media_type="application/problem+json",         # RFC 9457
            content={"type": "https://api.example.com/problems/rate-limit",
                     "title": "Too Many Requests", "status": 429,
                     "detail": "Превышен лимит запросов для этого клиента."},
            headers={"Retry-After": str(max(1, v.retry_after)),        # RFC 9110
                     "RateLimit-Limit": str(burst),
                     "RateLimit-Remaining": str(v.remaining),
                     "RateLimit-Reset": str(max(1, v.retry_after))},
        )
    response = await call_next(request)
    response.headers["RateLimit-Remaining"] = str(v.remaining)
    return response

Код 429 определён в RFC 6585 (https://www.rfc-editor.org/rfc/rfc6585), Retry-After — в RFC 9110 (https://www.rfc-editor.org/rfc/rfc9110), формат ошибки — RFC 9457 (https://www.rfc-editor.org/rfc/rfc9457, заменил 7807), семейство RateLimit-* стандартизуется черновиком draft-ietf-httpapi-ratelimit-headers (https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/). Отдавать заголовки стоит: честный клиент притормозит сам, а недобросовестному вы всё равно не сообщите ничего нового. Ответственность клиента — экспоненциальная задержка с джиттером; синхронные ретраи всех клиентов после сбоя создают ту самую лавину, от которой лимит и защищает (см. устойчивость).

Мораль схемы: rate limiting — не бинарный переключатель. Между «пропустить» и «забанить» лежат замедление, задача-испытание и сужение лимита; вечных блокировок без процедуры снятия быть не должно, иначе первым под них попадёт корпоративный NAT самого крупного клиента.

Как проверить:

def test_otp_endpoint_is_rate_limited(client, alice):
    codes = [client.post("/api/v1/otp", headers=alice.auth).status_code for _ in range(30)]
    assert 429 in codes, "эндпоинт отправки кода не ограничен по частоте"
    limited = client.post("/api/v1/otp", headers=alice.auth)
    assert limited.headers.get("Retry-After"), "нет Retry-After в ответе 429"

def test_forwarded_header_cannot_reset_the_key(client):
    """Подделанный X-Forwarded-For не должен давать новый бюджет."""
    for _ in range(30):
        client.post("/api/v1/auth/login", json=WRONG_CREDS)
    spoofed = client.post("/api/v1/auth/login", json=WRONG_CREDS,
                          headers={"X-Forwarded-For": "203.0.113.77"})
    assert spoofed.status_code == 429

Нагрузочную проверку — только на стенде и только в согласованном окне: «проверить лимиты» на проде без письменного согласования и уведомления дежурной смены неотличимо от атаки на отказ.

API5 и API6: функция и бизнес-поток

Broken Function Level Authorization — доступ к операции, а не к объекту: маршрут /api/v1/admin/*, проверяющий только валидность токена; DELETE там, где ревьюили GET; «внутренний» эндпоинт, доступный из интернета, «потому что его никто не знает». Лечится запретом по умолчанию: middleware требует, чтобы у каждого маршрута было объявлено нужное право, и тест падает, если объявления нет.

def test_every_route_declares_required_permission(app):
    undeclared = [r.path for r in app.routes
                  if r.path.startswith("/api/") and not getattr(r, "required_permission", None)]
    assert not undeclared, f"маршруты без объявленного права: {undeclared}"

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

API7: SSRF — когда ваш сервер ходит по чужой ссылке

CWE-918 (https://cwe.mitre.org/data/definitions/918.html). Появляется везде, где URL задаёт пользователь: вебхуки, импорт по ссылке, превью, «загрузить аватар по адресу», конвертеры документов.

# УЯЗВИМО: сервер сходит по любому адресу, включая внутренние.
@router.post("/api/v1/webhooks/test")
async def test_webhook(body: WebhookTest):
    async with httpx.AsyncClient() as c:
        r = await c.get(body.url)
    return {"status": r.status_code, "body": r.text[:1000]}

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

Как чинить, по убыванию надёжности: (1) не ходить по URL вовсе, если задача решается белым списком заранее зарегистрированных вебхуков; (2) вынести исходящие запросы в отдельный egress-прокси в сети без доступа к внутренним ресурсам — единственный способ, переживающий ошибки в коде; (3) если ни то ни другое невозможно — разрешительная проверка адреса:

import ipaddress, socket

BLOCKED = [ipaddress.ip_network(n) for n in (
    "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
    "172.16.0.0/12", "192.0.0.0/24", "192.168.0.0/16", "198.18.0.0/15", "224.0.0.0/4",
    "::1/128", "fc00::/7", "fe80::/10", "::ffff:0:0/96")]

def resolve_public(host: str, port: int) -> list[str]:
    """Резолвим ВСЕ адреса хоста и требуем, чтобы каждый был публичным."""
    addrs = {info[4][0] for info in socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)}
    if not addrs:
        raise ValidationError("хост не резолвится")
    for a in addrs:
        ip = ipaddress.ip_address(a)
        if any(ip in net for net in BLOCKED) or not ip.is_global:
            raise ValidationError("адрес назначения запрещён")
    return sorted(addrs)

Честная оговорка: сама по себе эта функция дырява из-за DNS rebinding — между проверкой и подключением DNS-ответ меняется (классический TOCTOU). Проверенный IP нужно закреплять и подключаться именно к нему, передавая Host и SNI исходного домена, а редиректы либо запрещать (follow_redirects=False), либо прогонять через ту же проверку на каждом шаге. Плюс обязательные: разрешены только схемы http/https, таймауты на соединение и чтение, лимит размера ответа, никакой пересылки заголовков авторизации на сторонний хост. В облаке отдельно включается IMDSv2 с обязательным токеном и hop-limit=1 — тогда сервис метаданных перестаёт отвечать на проксированные запросы (https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html).

Проверка на своём стенде: поднимите локальную ловушку и убедитесь, что отвергаются все четыре случая — 127.0.0.1, 169.254.169.254, домен, резолвящийся в приватный адрес, и редирект с публичного адреса на приватный. Развёрнутый список приёмов — в OWASP SSRF Prevention Cheat Sheet (https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html).

Ошибки, коды и утечка через различимость

Ответ об ошибке сообщает клиенту, что делать, и ничего — о внутреннем устройстве. Стек-трейс, текст SQL, имя таблицы, версия фреймворка в теле 500 — это CWE-209. Формат — application/problem+json по RFC 9457, с машиночитаемым type, стабильным title и без внутренних деталей в detail; идентификатор запроса отдавать полезно — поддержка найдёт подробности в логах, не раскрывая их клиенту.

Отдельная тонкость — выбор между 403 и 404. Если API отвечает 403 на существующий чужой объект и 404 на несуществующий, он превращается в оракул существования: перебором устанавливается, какие идентификаторы (номера договоров, телефоны, ИНН) есть в системе. Политика должна быть явной и единой: объект существует, но не виден субъекту → 404, как будто его нет; объект виден, но операция запрещена → 403; разница во времени ответа между этими случаями должна быть незначимой. Тот же принцип применяется в аутентификации к «пользователь не найден» против «неверный пароль».

API9 и API10: инвентарь и чужие интерфейсы

Классика забытого учёта: v1, который «все давно перевели на v2», но он поднят и не обновляется; тестовый контур с копией боевых данных и без лимитов; сервис, осиротевший после ухода команды; отладочный профилировщик, доступный снаружи. Кодом это не чинится — чинится реестром: сервис, версия, владелец, окружение, статус, дата вывода из эксплуатации; спецификация генерируется из кода и проверяется в CI; реальные маршруты шлюза и mesh сверяются с реестром, а расхождение разбирается как инцидент конфигурации; версии выводятся по расписанию с заголовками Deprecation/Sunset; непубличные окружения не смотрят в интернет и не содержат боевых данных.

def test_openapi_matches_reality(app, published_spec):
    """Расхождение реестра и кода — это инцидент, а не мелочь."""
    live = {(r.method, r.path) for r in app.routes if r.path.startswith("/api/")}
    documented = {(m.upper(), p) for p, ops in published_spec["paths"].items() for m in ops}
    assert live == documented, f"недокументированные: {live - documented}; призраки: {documented - live}"

API10, небезопасное потребление чужих API — зеркало всего перечисленного: ответ стороннего сервиса недоверенный. Ему нужны таймауты, лимит размера тела, строгая схема разбора, проверка TLS-сертификата (никогда verify=False), отдельный лимит конкурентности и подписанные вебхуки: HMAC-подпись, метка времени, окно допустимого расхождения и защита от повтора. Идемпотентность приёмника обязательна — см. доставку и идемпотентность.

Специфика стилей: GraphQL, gRPC, WebSocket

Всё сказанное про объекты, поля и расход остаётся в силе — меняются точки контроля. Сравнение самих стилей — в статье про стили API.

  • GraphQL. Авторизация принадлежит резолверам полей, а не «эндпоинту»: маршрут один на всё API. Обязательны ограничение глубины и расчёт стоимости запроса до выполнения, лимит на число операций в батче и на алиасы (десять тысяч алиасов одного поля обходят лимит по числу запросов), пагинация с потолком, отключение интроспекции в проде и, лучше всего, persisted queries — сервер исполняет только заранее зарегистрированные запросы. Ориентир: OWASP GraphQL Cheat Sheet (https://cheatsheetseries.owasp.org/cheatsheets/GraphQL_Cheat_Sheet.html).
  • gRPC. Лимит MaxRecvMsgSize, отключение reflection в проде, авторизация в перехватчике с запретом по умолчанию для незарегистрированных методов, ограничение числа потоков на соединение, mTLS на транспорте — см. транспортную безопасность.
  • WebSocket и SSE. Авторизация проверяется не только при рукопожатии, но и при каждой подписке на канал (иначе это BOLA по каналам), плюс лимит числа соединений и темпа сообщений на принципала. Проверка Origin на handshake обязательна: same-origin policy на WebSocket не распространяется.

Наблюдаемость: как понять, что вас уже перебирают

По каждому значимому запросу в журнал: идентификатор запроса, принципал и арендатор, метод и шаблон маршрута (не конкретный URL с данными), идентификатор объекта, решение авторизации с причиной отказа, результат лимитирования, код ответа и длительность. Чего в журнале быть не должно: токенов, паролей, полных номеров карт, персональных данных сверх необходимого — минимизация в логах разбирается в статье про приватность.

Сигналы для оповещений: всплеск 403/404 от одного принципала по разным идентификаторам объектов (перебор); рост доли 422 с одинаковым именем поля (подбор имён полей или сломанная интеграция); один принципал, читающий на порядок больше объектов, чем обычно (выгрузка); 429 с одного клиента подряд часами (сломанный ретрай либо автоматика); запросы к маршрутам, которых нет в реестре. Как строить сбор и корреляцию — в наблюдаемости.

Мини-итог: чек-лист ревью API

  1. У каждого маршрута объявлено требуемое право, политика — запрет по умолчанию, есть тест на отсутствие маршрутов без объявления.
  2. Каждый доступ к объекту фильтруется по владельцу или арендатору на уровне выборки, а не проверкой после загрузки; арендатор берётся только из проверенного токена.
  3. Работает матричный тест «чужой объект недоступен», покрывающий все маршруты с параметром пути.
  4. RLS или эквивалент включён как последний рубеж; приложение ходит под ролью без обхода политик.
  5. На вход — строгие схемы по операциям (strict / extra=forbid), включая вложенные объекты; внутренняя модель не используется как DTO.
  6. На выход — явная проекция полей; есть тест на точный набор ключей ответа.
  7. Ограничены размер тела, глубина и число узлов JSON, длины строк, размеры коллекций, limit пагинации (значение по умолчанию и максимум).
  8. Регулярные выражения без вложенных квантификаторов, длина входа ограничена до их применения.
  9. Лимит частоты атомарен, привязан к принципалу (а не только к адресу), учитывает стоимость операции; X-Forwarded-For берётся только от доверенного прокси.
  10. Вход, отправка кодов и восстановление доступа лимитированы и по источнику, и по учётной записи; поведение при недоступности хранилища лимитов выбрано осознанно.
  11. Ответ 429 содержит Retry-After и RateLimit-*; клиенты ретраят с джиттером.
  12. Ошибки — application/problem+json без внутренних деталей; политика 403/404 единая и не создаёт оракул существования.
  13. Исходящие запросы по пользовательскому URL идут через egress-прокси или проверку с закреплением IP, без редиректов и без пересылки заголовков авторизации.
  14. Реестр API соответствует коду, спецификация проверяется в CI, старые версии выводятся по расписанию, непубличные контуры недоступны из интернета.
  15. Аудит фиксирует решения авторизации и лимитирования; настроены оповещения на перебор и аномальный объём чтения.

Источники

Что дальше

Мы закрыли периметр самого интерфейса: кто что вызывает, над каким объектом, с какими полями и как часто. Но почти каждая из этих защит опирается на секрет — ключ подписи токенов, пароль к базе, учётные данные egress-прокси, HMAC-ключ вебхуков. Если секрет лежит в репозитории, в переменной окружения контейнера и в трёх чатах, всё построенное выше обходится одним украденным файлом. Дальше — где хранить секреты, как выдавать их сервисам, как ротировать без простоя и что делать в первые пятнадцать минут после утечки ключа.

Секреты и ключи: хранение, ротация, Vault, переменные окружения

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов