Проблема
API-команды всё чаще строят сервис как набор внутренних и публичных интерфейсов: мобильное приложение, SPA, партнёрские интеграции, webhooks, админские действия. Документация быстро отстаёт, а старые версии API продолжают жить ради совместимости.
В такой архитектуре самая дорогая ошибка - не SQL-инъекция, а неверная авторизация объекта. Пользователь авторизован, токен валиден, метод безопасный, но сервер не проверяет, принадлежит ли объект именно этому пользователю или роли.
Решение
Проверка должна начинаться с API inventory: OpenAPI, HAR, browser network log, GraphQL schema, sitemap и реальные redirect chains. Затем каждому endpoint присваиваются признаки: account, profile, billing, upload, invite, admin, object-id.
Для production безопасный первый слой - только read-only replay: GET/HEAD/OPTIONS, без тела запроса, без массового перебора и без мутаций. Две тестовые личности с разными ролями выполняют одинаковые чтения, а validator сравнивает статус, redirect, content-type, hash, размер и self-markers.
Что проверить руками
- Есть ли минимум две owned test identities: обычный пользователь, менеджер, админ или viewer.
- Все ли object identifiers из HAR/OpenAPI попали в карту: id, userId, accountId, profileId, orderId, UUID в path/query.
- Отличаются ли ответы между owner и non-owner не только статусом, но и смысловым маркером объекта.
- Не превращает ли CDN/WAF блок-страницу в ложный вывод о безопасности приложения.
Как это должно попадать в отчёт
- Finding создаётся только если есть role-differential evidence: запрос владельца, запрос другой роли, сравнение ответа и критерий ретеста.
- Если есть только подозрительный endpoint, но нет двух ролей или HAR, это coverage gap, а не уязвимость.
- Для бизнеса вывод формулируется через данные и процесс: какие объекты могли быть раскрыты, кто владелец риска, какой SLA и как доказать исправление.
Безопасный пример: извлечь object endpoints из HAR без сохранения cookie
import json
import re
from pathlib import Path
from urllib.parse import urlparse, parse_qs
OBJECT_KEYS = {"id", "userId", "accountId", "profileId", "orderId"}
SECRET_HEADERS = {"authorization", "cookie", "set-cookie", "x-api-key"}
def redacted_headers(headers):
result = {}
for item in headers or []:
name = item.get("name", "").lower()
result[name] = "<redacted>" if name in SECRET_HEADERS else item.get("value", "")
return result
def extract_inventory(har_path):
har = json.loads(Path(har_path).read_text(encoding="utf-8"))
operations = []
for entry in har.get("log", {}).get("entries", []):
req = entry.get("request", {})
method = req.get("method", "GET").upper()
if method not in {"GET", "HEAD", "OPTIONS"}:
continue
parsed = urlparse(req.get("url", ""))
params = set(parse_qs(parsed.query))
path_ids = set(re.findall(r"/([0-9a-fA-F-]{8,}|[0-9]{2,})", parsed.path))
if params & OBJECT_KEYS or path_ids:
operations.append({
"method": method,
"host": parsed.netloc,
"path": parsed.path,
"object_params": sorted(params & OBJECT_KEYS),
"headers": redacted_headers(req.get("headers")),
})
return operations
print(json.dumps(extract_inventory("session.har"), indent=2, ensure_ascii=False))