CWE, OWASP, криптография, аутентификация, авторизация
Инъекцию и секрет в коде поймает линтер. Уязвимости этого уровня выглядят как корректный код и находятся только пониманием модели угроз.
Вы научитесь проверять авторизацию как систему, а не как декоратор; замечать ошибки в работе с токенами и криптографией; находить SSRF, небезопасную десериализацию и утечки через побочные каналы; и оценивать риск изменений в цепочке поставок.
Базовые классы — инъекции, XSS, секреты, валидация — в теме Проверка безопасности курса основ. Здесь предполагается, что их вы уже видите автоматически.
Отсутствие проверки прав — самая частая критичная уязвимость в продуктовом коде, и она почти никогда не выглядит как отсутствие проверки. Обычно проверка есть, но проверяет не то.
Вопрос первый: аутентификация или авторизация? @login_required отвечает на «кто», а не на «можно ли». Различие стоит утечек.
Вопрос второй: проверяется субъект или объект? Роль пользователя (is_admin) — свойство субъекта. Принадлежность заказа — свойство связи субъекта и объекта. Проверка роли не заменяет проверку принадлежности.
# ❌ проверка роли есть, проверки принадлежности нет:
# любой менеджер прочитает документы любого отдела
@app.get("/documents/{doc_id}")
@require_role("manager")
def get_document(doc_id: int):
return Document.query.get(doc_id)Вопрос третий: где проверка — в каждом обработчике или в одном месте? Проверка, повторяемая в сорока обработчиках, будет забыта в сорок первом. Это не гипотеза, а закономерность.
# ✅ проверка встроена в получение данных и не может быть пропущена
def documents_visible_to(user: User) -> Query:
return Document.query.filter(Document.department_id.in_(user.department_ids))
@app.get("/documents/{doc_id}")
def get_document(doc_id: int, user=Depends(current_user)):
doc = documents_visible_to(user).filter_by(id=doc_id).first()
if doc is None:
abort(404) # не 403: не подтверждаем существование
return DocumentResponse.from_orm(doc)Обратите внимание на 404 вместо 403. Различие в кодах ответа — побочный канал: 403 подтверждает, что объект существует, и позволяет перебором составить карту чужих данных.
Что ещё смотреть на этом уровне:
"role": "admin" или "balance": 999999. Нужен явный список разрешённых полей, а не исключение запрещённых.PATCH /orders/{id} с полем user_id в теле позволяет переписать заказ на другого пользователя.Проверьте себя. Возьмите три эндпоинта проекта и для каждого назовите, где проверяется принадлежность объекта.
Частая ошибка. Проверять права после загрузки объекта. Работает, но забывается; поиск в пределах прав — не забывается.
Проверка подписи. Библиотеки JWT исторически позволяли декодировать токен без проверки — jwt.decode(token, options={"verify_signature": False}) или указание алгоритма none. Если в диффе встречается декодирование без явного алгоритма и ключа, это blocker.
# ❌ алгоритм берётся из самого токена — атакующий укажет "none"
payload = jwt.decode(token, key, algorithms=None)
# ✅ алгоритм фиксирован нами
payload = jwt.decode(token, key, algorithms=["RS256"], audience=API_AUDIENCE)Срок жизни и отзыв. Токен без exp действителен вечно. Но важнее вопрос отзыва: JWT по устройству неотзываем — сервер не хранит состояние. Значит, «выйти со всех устройств» и блокировка скомпрометированного аккаунта требуют либо короткого срока жизни с обновлением, либо списка отозванных. Если в PR появляется аутентификация по JWT, спросите: что произойдёт, когда понадобится немедленно отключить пользователя.
Что лежит в токене. Полезная нагрузка JWT не зашифрована, а только подписана — она читается любым, кто её получил. Персональные данные, внутренние идентификаторы и роли в открытом виде — утечка. Плюс данные в токене устаревают: роль, отозванная в базе, останется в токене до истечения срока.
Регенерация сессии. После входа идентификатор сессии обязан меняться — иначе возможна фиксация сессии: атакующий подсовывает жертве известный ему идентификатор, жертва входит, идентификатор остаётся тем же.
Флаги cookie. HttpOnly (недоступна из JavaScript), Secure (только по HTTPS), SameSite=Lax или Strict (защита от межсайтовых запросов). Отсутствие любого — замечание.
Проверьте себя. Декодируйте свой токен на любом просмотрщике JWT и посмотрите, что в нём видно без ключа.
Частая ошибка. Считать JWT в localStorage безопасным решением. Он доступен любому скрипту на странице, то есть любой XSS превращается в кражу сессии.
Правило простое: криптографические примитивы не собираются руками. В ревью достаточно проверить выбор инструмента.
Пароли. Только медленные функции с солью: Argon2id, bcrypt, scrypt. sha256(password) — уязвимость, независимо от количества раундов вручную; SHA спроектирована быстрой, что здесь ровно противоположно нужному.
# ❌ быстрый хеш: перебор на GPU идёт миллиардами в секунду
hashed = hashlib.sha256(password.encode()).hexdigest()
# ✅
hashed = argon2.PasswordHasher().hash(password)Случайность. random не криптографический: последовательность предсказуема по нескольким значениям. Для токенов, паролей, ключей сброса — только secrets (Python) или crypto.randomUUID / getRandomValues (JS).
token = random.choice(...) # ❌ предсказуемо
token = secrets.token_urlsafe(32) # ✅Шифрование. Своя схема на XOR или AES в режиме ECB — уязвимость. Используйте готовое: cryptography.fernet, libsodium, AES-GCM с уникальным одноразовым значением на каждое сообщение. Повторное использование nonce в GCM ломает защиту полностью.
Сравнение секретов. Обычное == для строк завершается на первом различии, и время ответа выдаёт длину совпавшего префикса. Для подписей, токенов и ключей — hmac.compare_digest.
if signature == expected: # ❌ утечка через время
if hmac.compare_digest(signature, expected): # ✅Проверьте себя. Найдите в проекте все вхождения hashlib и random и проверьте, для чего они используются.
Частая ошибка. Оценивать криптографию по надёжности алгоритма, не проверив режим и работу с одноразовыми значениями. AES сам по себе стойкий; AES-ECB — нет.
Уязвимость, которая почти всегда выглядит как полезная функция: «дай нам URL, мы загрузим оттуда картинку / проверим вебхук / импортируем данные».
# ❌ сервер сходит по любому адресу, включая внутренние
@app.post("/import")
def import_from_url(url: str):
return requests.get(url).textАтакующий подставляет http://169.254.169.254/latest/meta-data/ (метаданные облака с временными ключами доступа), http://localhost:6379/ (Redis без пароля, доступный только изнутри) или адрес внутреннего сервиса. Сетевой периметр обходится, потому что запрос идёт из доверенной зоны.
Что должно быть в диффе, где сервер ходит по пользовательскому URL:
http и https (не file://, не gopher://);Проверка одной строкой не работает: домен, который сейчас разрешается в публичный адрес, при повторном запросе разрешится во внутренний — это называется перепривязкой DNS. Поэтому проверяют адрес, а не имя, и соединяются именно с проверенным адресом.
Проверьте себя. Есть ли в проекте эндпоинт, принимающий URL от пользователя? Что произойдёт, если передать http://localhost/?
Частая ошибка. Считать защитой чёрный список доменов. Приватные диапазоны конечны — их и надо перечислять, но со стороны адресов.
Форматы, способные восстанавливать произвольные объекты, при недоверенном вводе означают выполнение кода:
pickle.loads(data) # ❌ произвольный код
yaml.load(data) # ❌ без SafeLoader — конструирует объекты
eval(expr) / exec(code) # ❌
jinja2.Template(user_input) # ❌ внедрение в шаблонБезопасные замены: json, yaml.safe_load, разбор выражений специализированным парсером, шаблоны с данными в контексте, а не в тексте шаблона.
Отдельный случай — динамический импорт по имени из запроса (importlib.import_module(name)) и обращение к атрибуту по строке (getattr(obj, request.args["field"])). Оба дают доступ к внутренностям объекта, включая __class__ и дальше.
Проверьте себя. Поищите pickle, yaml.load, eval и exec в проекте и проверьте источник данных для каждого.
Частая ошибка. Считать pickle безопасным, потому что данные приходят «из своей очереди». Если в очередь можно что-то положить, это вектор.
Изменения в файлах блокировки зависимостей — часть ревью, а не служебный шум.
Что смотреть: появились ли новые транзитивные зависимости и сколько; выглядит ли имя пакета как опечатка известного (reqeusts, python-dateutils); зафиксирована ли версия; кто сопровождает пакет и когда был последний выпуск; нужен ли пакет вообще ради одной функции.
Особое внимание — установочным скриптам: пакет с setup.py, выполняющим код при установке, запускает его в вашем CI с доступом к секретам сборки.
Автоматизируемая часть — аудит уязвимостей (pip-audit, npm audit, Dependabot) и проверка происхождения артефактов. Ручная часть — вопрос «зачем нам эта зависимость».
Проверьте себя. Посмотрите, сколько транзитивных зависимостей добавил последний новый пакет в вашем проекте.
Частая ошибка. Просматривать только прямые зависимости. Компрометация приходит транзитивной.
Функция для поставщиков: указать URL прайс-листа, сервис скачает и разберёт его.
+@app.post("/supplier/import")
+@login_required
+def import_prices(url: str, supplier_id: int, user=Depends(current_user)):
+ resp = requests.get(url)
+ data = yaml.load(resp.text)
+
+ token = str(random.randint(100000, 999999))
+ ImportJob.objects.create(
+ supplier_id=supplier_id, token=token, raw=resp.text
+ )
+
+ for item in data["items"]:
+ Product.objects.update_or_create(
+ sku=item["sku"], defaults={**item, "supplier_id": supplier_id}
+ )
+
+ logger.info(f"import by {user.email}: {resp.text[:500]}")
+ return {"token": token, "imported": len(data["items"])}Что видит ревьюер. Формально всё работает, @login_required на месте. При этом здесь пять уязвимостей разного класса.
supplier_id приходит параметром и никак не сверяется с правами пользователя — любой поставщик перезапишет прайс конкурента. Это IDOR в чистом виде: аутентификация есть, авторизации нет.
requests.get(url) — SSRF: сервер сходит по любому адресу, включая метаданные облака и внутренние сервисы. Без таймаута, без ограничения размера, со следованием за перенаправлениями по умолчанию.
yaml.load без SafeLoader конструирует произвольные объекты Python — то есть удалённое выполнение кода в теле прайс-листа, который поставщик полностью контролирует.
random.randint для токена: шестизначное предсказуемое число из некриптографического генератора. Если этот токен даёт доступ к результатам импорта, он перебирается за минуты.
{**item, ...} — массовое присваивание: поставщик кладёт в прайс-лист поле is_featured или cost_price и меняет то, что менять не должен.
Плюс лог: resp.text[:500] пишет в журнал содержимое загруженного файла, а raw=resp.text сохраняет его целиком в базу без ограничения размера.
Комментарии в PR:
import.py:5· blockeryaml.loadбезSafeLoader— удалённое выполнение кода. Содержимое файла полностью контролируется поставщиком, тег!!python/object/apply:os.systemдаст ему шелл на нашем сервере. Нуженyaml.safe_load, а лучше — явная схема разбора с валидацией.
import.py:4· blocker SSRF: сервер сходит по любому URL от пользователя.http://169.254.169.254/latest/meta-data/iam/security-credentials/вернёт временные ключи доступа облака,http://localhost:6379— наш Redis. Нужно: разрешить толькоhttp/https, разрешить имя в адрес и проверить, что он не в приватных диапазонах, соединяться с проверенным адресом, запретить перенаправления, поставить таймаут и предел размера.
import.py:3· blockersupplier_idберётся из параметров и не сверяется с правами: любой поставщик перезапишет прайс-лист конкурента. Берите поставщика из связи с текущим пользователем, а не из запроса.
import.py:7· blockerrandom.randintне криптографический, и шестизначное число перебирается целиком. Если этот токен даёт доступ к результатам импорта — это открытая дверь. Нуженsecrets.token_urlsafe(32).
import.py:13· major{**item}вdefaults— массовое присваивание: любое поле из файла попадёт в модель, включаяcost_priceиis_featured. Перечислите разрешённые поля явно.
import.py:16· major Содержимое загруженного файла пишется в лог и целиком сохраняется в БД без ограничения размера. Файл на гигабайт положит и то, и другое. Ограничьте размер при чтении и логируйте только метаданные — размер, число позиций, контрольную сумму.
import.py:11· question Импорт синхронный внутри HTTP-запроса. При прайсе на десять тысяч позиций это таймаут и частично применённое изменение. Стоит вынести в задачу — и заодно решится вопрос с размером.
Чем закончилось. Разбор YAML заменили на safe_load со схемой; загрузку — на функцию с белым списком схем, проверкой адреса, запретом перенаправлений, таймаутом и пределом в 10 МБ; supplier_id убрали из сигнатуры; токен — secrets; поля — явный список. Импорт вынесли в фоновую задачу.
Вопрос про синхронность оказался важнее, чем выглядел: без него частично применённый импорт оставлял часть товаров с новыми ценами, а часть со старыми, без возможности понять, где остановился.
Обратите внимание: @login_required был на месте с самого начала. Именно поэтому уязвимости этого уровня не находятся сканером — код выглядит защищённым.
АВТОРИЗАЦИЯ проверяется принадлежность объекта, а не только роль; поиск в пределах прав
ОТВЕТЫ 404 вместо 403 там, где существование объекта само является данными
ПОЛЯ явный белый список; тело запроса не льётся в модель
ТОКЕНЫ алгоритм фиксирован; есть exp; продуман отзыв; в нагрузке нет лишнего
COOKIE HttpOnly, Secure, SameSite
КРИПТО Argon2/bcrypt для паролей; secrets для случайности; готовые режимы шифрования
СРАВНЕНИЕ секреты сравниваются постоянным по времени способом
SSRF белый список схем; проверка адреса, а не имени; без перенаправлений
ФОРМАТЫ никакого pickle, yaml.load, eval на недоверенных данных
ЗАВИСИМОСТИ новые обоснованы; транзитивные просмотрены; аудит в CIБазовые классы уязвимостей — Проверка безопасности. Пределы и защита от исчерпания ресурсов — Масштабируемость. Автоматизация проверок — Автоматизация. Тренировка: Code Review Python → Безопасность.
Ключевая мысль: уязвимости этого уровня живут не в отсутствующих проверках, а в проверках, которые проверяют не то. Сканер их не находит именно потому, что код выглядит защищённым.
Далее: Масштабируемость