Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Глубокая проверка безопасности
security_deep_dive

Глубокая проверка безопасности

CWE, OWASP, криптография, аутентификация, авторизация

Безопасность: глубокий разбор

Инъекцию и секрет в коде поймает линтер. Уязвимости этого уровня выглядят как корректный код и находятся только пониманием модели угроз.

#Результат урока

Вы научитесь проверять авторизацию как систему, а не как декоратор; замечать ошибки в работе с токенами и криптографией; находить SSRF, небезопасную десериализацию и утечки через побочные каналы; и оценивать риск изменений в цепочке поставок.

Базовые классы — инъекции, XSS, секреты, валидация — в теме Проверка безопасности курса основ. Здесь предполагается, что их вы уже видите автоматически.

#1. Авторизация: три вопроса вместо декоратора

Отсутствие проверки прав — самая частая критичная уязвимость в продуктовом коде, и она почти никогда не выглядит как отсутствие проверки. Обычно проверка есть, но проверяет не то.

Вопрос первый: аутентификация или авторизация? @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. Нужен явный список разрешённых полей, а не исключение запрещённых.
  • Проверка на клиенте. Скрытая кнопка не мешает вызвать API напрямую.
  • Права на уровне действия, а не только ресурса. Пользователь имеет доступ к заказу — но имеет ли право его отменить после отправки?
  • Смена владельца. Эндпоинт PATCH /orders/{id} с полем user_id в теле позволяет переписать заказ на другого пользователя.

Проверьте себя. Возьмите три эндпоинта проекта и для каждого назовите, где проверяется принадлежность объекта.

Частая ошибка. Проверять права после загрузки объекта. Работает, но забывается; поиск в пределах прав — не забывается.

#2. Токены и сессии

Проверка подписи. Библиотеки 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 превращается в кражу сессии.

#3. Криптография: что можно и чего нельзя

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

Пароли. Только медленные функции с солью: 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 — нет.

#4. SSRF

Уязвимость, которая почти всегда выглядит как полезная функция: «дай нам 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://);
  • разрешение имени в адрес до запроса и проверка, что адрес не в приватных диапазонах, включая loopback, link-local и IPv6-эквиваленты;
  • запрет следования за перенаправлениями либо повторная проверка на каждом шаге — иначе публичный адрес перенаправит на внутренний;
  • таймаут и предел на размер ответа;
  • в идеале — отдельный исходящий прокси с белым списком.

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

Проверьте себя. Есть ли в проекте эндпоинт, принимающий URL от пользователя? Что произойдёт, если передать http://localhost/?

Частая ошибка. Считать защитой чёрный список доменов. Приватные диапазоны конечны — их и надо перечислять, но со стороны адресов.

#5. Десериализация и выполнение кода

Форматы, способные восстанавливать произвольные объекты, при недоверенном вводе означают выполнение кода:

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 безопасным, потому что данные приходят «из своей очереди». Если в очередь можно что-то положить, это вектор.

#6. Цепочка поставок

Изменения в файлах блокировки зависимостей — часть ревью, а не служебный шум.

Что смотреть: появились ли новые транзитивные зависимости и сколько; выглядит ли имя пакета как опечатка известного (reqeusts, python-dateutils); зафиксирована ли версия; кто сопровождает пакет и когда был последний выпуск; нужен ли пакет вообще ради одной функции.

Особое внимание — установочным скриптам: пакет с setup.py, выполняющим код при установке, запускает его в вашем CI с доступом к секретам сборки.

Автоматизируемая часть — аудит уязвимостей (pip-audit, npm audit, Dependabot) и проверка происхождения артефактов. Ручная часть — вопрос «зачем нам эта зависимость».

Проверьте себя. Посмотрите, сколько транзитивных зависимостей добавил последний новый пакет в вашем проекте.

Частая ошибка. Просматривать только прямые зависимости. Компрометация приходит транзитивной.

#7. Разбор: PR «Импорт товаров по ссылке на прайс-лист»

Функция для поставщиков: указать 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 · blocker yaml.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 · blocker supplier_id берётся из параметров и не сверяется с правами: любой поставщик перезапишет прайс-лист конкурента. Берите поставщика из связи с текущим пользователем, а не из запроса.

import.py:7 · blocker random.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 был на месте с самого начала. Именно поэтому уязвимости этого уровня не находятся сканером — код выглядит защищённым.

#8. Чек-лист

АВТОРИЗАЦИЯ проверяется принадлежность объекта, а не только роль; поиск в пределах прав ОТВЕТЫ 404 вместо 403 там, где существование объекта само является данными ПОЛЯ явный белый список; тело запроса не льётся в модель ТОКЕНЫ алгоритм фиксирован; есть exp; продуман отзыв; в нагрузке нет лишнего COOKIE HttpOnly, Secure, SameSite КРИПТО Argon2/bcrypt для паролей; secrets для случайности; готовые режимы шифрования СРАВНЕНИЕ секреты сравниваются постоянным по времени способом SSRF белый список схем; проверка адреса, а не имени; без перенаправлений ФОРМАТЫ никакого pickle, yaml.load, eval на недоверенных данных ЗАВИСИМОСТИ новые обоснованы; транзитивные просмотрены; аудит в CI

#Что дальше

Базовые классы уязвимостей — Проверка безопасности. Пределы и защита от исчерпания ресурсов — Масштабируемость. Автоматизация проверок — Автоматизация. Тренировка: Code Review Python → Безопасность.


Ключевая мысль: уязвимости этого уровня живут не в отсутствующих проверках, а в проверках, которые проверяют не то. Сканер их не находит именно потому, что код выглядит защищённым.

Далее: Масштабируемость