Безопасность API: rate limiting, IDOR, массовое присвоение, валидация
У веб-страницы есть посредник — браузер, который что-то не покажет и куда-то не даст нажать. У API посредника нет: клиент API — программа, и она отправит ровно то, что захочет её автор: любой путь, любой метод, любые поля, любой идентификатор, миллион раз в секунду. Всё, что «спрятано» в мобильном приложении или SPA, лежит у пользователя на устройстве и разбирается за вечер; полагаться на это — CWE-602, перенос проверки на клиента (https://cwe.mitre.org/data/definitions/602.html).
Отсюда следствие, вокруг которого построена статья: сервер обязан считать каждое поле каждого запроса враждебным и принимать все решения сам. Решений при этом не одно, а четыре — и их регулярно путают.
Классификация не выдумана: она почти дословно совпадает со структурой 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 почти не находят: сканер не знает, какой объект чей. Ищут матричными тестами и на ревью.
(свои аккаунты, письменное разрешение) participant A as Клиент: аккаунт A participant API as API participant DB as Хранилище Note over T,DB: Легальная проверка: объект создан аккаунтом B, запрашивает аккаунт A A->>API: GET /v1/invoices/{id объекта аккаунта B} API->>API: токен валиден → аутентификация пройдена rect rgb(140, 90, 80) API->>DB: SELECT * FROM invoices WHERE id = :id DB-->>API: строка аккаунта B API-->>A: 200 OK + чужие данные ← BOLA end Note over API,DB: После исправления область видимости встроена в запрос A->>API: тот же запрос API->>DB: SELECT … WHERE id = :id AND tenant_id = :из_токена DB-->>API: пусто API-->>A: 404 Not Found (неотличимо от «нет объекта»)
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». Это три разные проверки в строгом порядке, потому что каждая следующая опирается на результат предыдущей.
размер тела, Content-Type,
таймаут чтения"} B -->|"превышено"| E1["413 / 415 — до разбора тела"] B -->|"в пределах"| C{"Синтаксис:
схема, типы, длины,
глубина, размер массивов"} C -->|"не проходит"| E2["422 с указанием поля"] C -->|"проходит"| D{"Семантика:
дата в прошлом, сумма > 0,
валюта из справочника"} D -->|"не проходит"| E3["422 — доменное правило"] D -->|"проходит"| F{"Авторизация:
маршрут → объект → поля"} F -->|"нет прав"| E4["403 или 404 по политике"] F -->|"есть"| G{"Расход:
частота, квота,
стоимость операции"} G -->|"исчерпан"| E5["429 + Retry-After"] G -->|"есть бюджет"| H["Выполнение операции"] H --> I["Проекция ответа по белому списку полей"] I --> J["Аудит: кто, что, над каким объектом, решение"]
Порядок не декоративный: разбирать 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 (контроль частоты взаимодействия). Зачем это нужно, кроме доступности:
- Подбор паролей, кодов из SMS, токенов восстановления: без лимита шестизначный код перебирается за секунды — см. аутентификацию.
- Массовое извлечение данных: каждый ответ легален, но за ночь выгружается вся база контрагентов. Формально не взлом, фактически утечка.
- Прямые деньги: SMS, письма, платные вызовы внешних сервисов, токены языковых моделей. Незалимиченный
POST /v1/otp— это счёт, который выставят вам. - Справедливость: один клиент с ретраями без джиттера кладёт сервис остальным.
Выбор алгоритма
| Алгоритм | Состояние на ключ | Поведение | Когда брать |
|---|---|---|---|
| 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/). Отдавать заголовки стоит: честный клиент притормозит сам, а недобросовестному вы всё равно не сообщите ничего нового. Ответственность клиента — экспоненциальная задержка с джиттером; синхронные ретраи всех клиентов после сбоя создают ту самую лавину, от которой лимит и защищает (см. устойчивость).
N минут подряд Подозрение --> Проверка : добавлен challenge
или сужен лимит Проверка --> Норма : поведение нормализовалось Проверка --> Блокировка : признаки автоматизации подтвердились Блокировка --> Карантин : истёк срок блокировки Карантин --> Норма : период без нарушений Карантин --> Блокировка : нарушение повторилось note right of Блокировка Всегда конечный срок и запись в аудит: кто, когда, по какому правилу, как снять end note
Мораль схемы: 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
- У каждого маршрута объявлено требуемое право, политика — запрет по умолчанию, есть тест на отсутствие маршрутов без объявления.
- Каждый доступ к объекту фильтруется по владельцу или арендатору на уровне выборки, а не проверкой после загрузки; арендатор берётся только из проверенного токена.
- Работает матричный тест «чужой объект недоступен», покрывающий все маршруты с параметром пути.
- RLS или эквивалент включён как последний рубеж; приложение ходит под ролью без обхода политик.
- На вход — строгие схемы по операциям (
strict/extra=forbid), включая вложенные объекты; внутренняя модель не используется как DTO. - На выход — явная проекция полей; есть тест на точный набор ключей ответа.
- Ограничены размер тела, глубина и число узлов JSON, длины строк, размеры коллекций,
limitпагинации (значение по умолчанию и максимум). - Регулярные выражения без вложенных квантификаторов, длина входа ограничена до их применения.
- Лимит частоты атомарен, привязан к принципалу (а не только к адресу), учитывает стоимость операции;
X-Forwarded-Forберётся только от доверенного прокси. - Вход, отправка кодов и восстановление доступа лимитированы и по источнику, и по учётной записи; поведение при недоступности хранилища лимитов выбрано осознанно.
- Ответ 429 содержит
Retry-AfterиRateLimit-*; клиенты ретраят с джиттером. - Ошибки —
application/problem+jsonбез внутренних деталей; политика 403/404 единая и не создаёт оракул существования. - Исходящие запросы по пользовательскому URL идут через egress-прокси или проверку с закреплением IP, без редиректов и без пересылки заголовков авторизации.
- Реестр API соответствует коду, спецификация проверяется в CI, старые версии выводятся по расписанию, непубличные контуры недоступны из интернета.
- Аудит фиксирует решения авторизации и лимитирования; настроены оповещения на перебор и аномальный объём чтения.
Источники
- OWASP API Security Top 10 2023 — BOLA, BOPLA, расход ресурсов, BFLA, бизнес-потоки, SSRF, инвентарь, потребление чужих API
- OWASP ASVS — проверяемые требования по уровням; разделы про контроль доступа, валидацию и API
- OWASP Cheat Sheets: REST Security, Mass Assignment, Authorization, SSRF Prevention, Denial of Service, GraphQL
- OWASP Web Security Testing Guide и NIST SP 800-115 — как организовать авторизованную проверку
- CWE: 639 IDOR, 284 контроль доступа, 285 авторизация, 915 массовое присвоение, 213 раскрытие полей, 770 и 799 отсутствие лимитов, 918 SSRF, 1333 ReDoS, 209 утечка через ошибки, 602 проверки на клиенте
- RFC: 9110 HTTP-семантика и
Retry-After, 6585 код 429, 9457 Problem Details, 7239Forwarded, 8259 JSON, draft-ietf-httpapi-ratelimit-headers заголовки лимитов - NIST: SP 800-204 — защита микросервисных систем; SP 800-207 — Zero Trust, откуда растёт «проверять на каждом вызове»
- OpenAPI Specification, JSON Schema, Google API Improvement Proposals — контракт как источник истины
- PostgreSQL Row Security Policies и Schemathesis — изоляция арендаторов и property-based тесты по спецификации
- Разборы лимитирования: Stripe о rate limiters, Cloudflare о sliding window, Brandur Leach о GCRA
- Neil Madden, «API Security in Action», Manning — самая полная книга по теме, от токенов до лимитов и изоляции
Что дальше
Мы закрыли периметр самого интерфейса: кто что вызывает, над каким объектом, с какими полями и как часто. Но почти каждая из этих защит опирается на секрет — ключ подписи токенов, пароль к базе, учётные данные egress-прокси, HMAC-ключ вебхуков. Если секрет лежит в репозитории, в переменной окружения контейнера и в трёх чатах, всё построенное выше обходится одним украденным файлом. Дальше — где хранить секреты, как выдавать их сервисам, как ротировать без простоя и что делать в первые пятнадцать минут после утечки ключа.
Секреты и ключи: хранение, ротация, Vault, переменные окружения