Неделя 123. Великий пивот CRM и аудит безопасности

Разбор этой статьи
Эту тему разобрали в подкасте. Слушай параллельно с чтением.
Есть коммиты, после которых нельзя вернуться. На неделе 123 мы сделали один такой: удалили восемь старых разделов платформы и заменили их полноценным CRM-модулем за один коммит в 91 файл. Параллельно обнаружили и закрыли четыре уязвимости безопасности. Это была неделя, когда строительство нового и защита старого шли одновременно, коммит за коммитом.
Я Дмитрий Дьяконов, основатель Botseller AI. Это сорок второй выпуск серии «Ретроспектива», бортжурнал в прошлое: от настоящего к первому коммиту. Сегодня отматываю на 8-14 декабря 2025 года. Продукту пять месяцев. Вы уже знаете наш CRM по предыдущим выпускам: спортзалы, детские центры, салоны, конвейер сделок. Вот как он родился.
Контекст: продукт как коллекция разрозненных страниц
Середина декабря, до Нового года две недели. Боты работают, клиенты платят, но платформа устроена как коллекция отдельных страниц: боты отдельно, файлы отдельно, промпты отдельно, логи отдельно. Каждая страница живёт сама по себе, и заказчик вынужден прыгать между восемью независимыми разделами, чтобы собрать картину по одному клиенту.

Мы уже решили, что CRM будет жить внутри платформы как единое ядро, а не сбоку как очередная вкладка. Читатели серии помнят это решение из выпуска про неделю 131. Но пока CRM существует только как идея и несколько таблиц в базе.
И в этот момент, посреди стройки, мы начинаем находить дыры в безопасности. Не одну, а четыре за неделю. Каждая из них требует немедленного исправления. Остановить стройку нельзя: декабрь, подписки проданы, заказчики ждут CRM. Игнорировать уязвимости нельзя: любая из них может привести к утечке данных. Остаётся единственный вариант: делать оба дела одновременно.
Суббота, 13 декабря. Точка невозврата
В субботу произошёл коммит, после которого платформа стала другой. Один коммит, 91 файл, 7 587 строк добавлено, 8 432 строки удалены.

Что было удалено: страницы ботов, CRM-переменных, файлов, логов, промптов, базы знаний, пользователей и конструктора агентов. Восемь разделов, каждый из которых жил как отдельная страница.
Что появилось вместо них: CRM-модуль с единой навигацией. Филиалы. Тренеры с системой бронирования. Расписание с недельным календарём. Услуги. Абонементы с историей покупок. Дети как отдельная сущность. Родители с боковой панелью деталей. Вся информация по одному заказчику собрана в одном месте.
Деталь, которая сегодня кажется безумной: у нас не было автотестов. Ни одного. Проверяли руками, каждый раздел, каждую кнопку. До трёх часов ночи.
Почему 91 файл в одном коммите

Потому что разнести не получалось. Старая навигация и новая не могут существовать одновременно: маршруты пересекаются, компоненты конфликтуют, пользователь видит дублирующиеся пункты меню. Нужно было либо сносить всё и строить заново, либо месяц поддерживать две параллельные архитектуры.
Мы выбрали путь Б: радикальный снос. 91 файл означает, что если хоть один импорт сломан, не работает ничего. Но это честный подход: либо работает всё, либо ошибка видна немедленно. Мы выбрали острую боль на одну ночь вместо тупой боли на месяц.
Оглядываясь назад: это был правильный выбор. Но страшный.
Второй фронт: внезапный аудит безопасности
Параллельно с CRM на этой неделе мы нашли и закрыли четыре уязвимости. Не потому, что стали хуже писать код. А потому, что впервые начали целенаправленно искать.

Матрица угроз: 4 уязвимости за 7 дней

Первая: критическая уязвимость в ядре фронтенд-фреймворка. Фикс: одна строка кода, но за ней стоит полный аудит всего, что эта уязвимость могла затронуть.
Вторая: IDOR в микросервисах. Наша система сервисов не проверяла, имеет ли пользователь право видеть запрашиваемые данные. Подмена идентификатора в запросе открывала чужую информацию. Фикс затронул 19 файлов: мы создали базовый класс, который принудительно фильтрует данные по принадлежности. Все 17 сервисов переписали за один вечер.
Третья: эндпоинт настроек принимал пустую строку как фильтр и возвращала базу всех заказчиков. Пустой параметр, пустой фильтр, полная утечка. Фикс занял два захода: первая попытка сломала валидацию, вторая сработала.
Четвёртая попала бы в анекдот, если бы не была опасной. Эндпоинт логина возвращал «Успешно» при неверном пароле. Вводишь любой пароль, получаешь ответ «всё хорошо». Токен не выдаётся, но и ошибки не видишь. Система авторизации, которая не умеет отказывать. Это не система. Это просто форма.
Кто читал выпуск про неделю 129, помнит, что я честно датировал первую трещину изоляции данных. Вот она: неделя 123.
Deep Dive: трещина изоляции данных (IDOR)

IDOR (Insecure Direct Object Reference) означает, что пользователь может получить доступ к чужим данным, просто подменив идентификатор в запросе. Для SaaS-платформы, где десятки компаний хранят данные на одном сервере, это критическая уязвимость.
Мы закрыли её архитектурно: создали единый базовый класс, принудительно фильтрующий данные по принадлежности. За один вечер 17 сервисов переписаны для наследования от него. Централизованная защита вместо семнадцати индивидуальных проверок.
Полночный RAG: анатомия гонки состояний
Параллельно с CRM мы подключали базу знаний к редактору инструкций. Идея простая: заказчик загружает документы, система их индексирует, бот использует при ответах.

Проблема: после загрузки документа система запускала индексацию, но интерфейс не дожидался результата и показывал пустую базу. Два процесса работают параллельно, и тот, что быстрее, ломает результат того, что медленнее.
4 шага к истине (2 ночи дебага)

Исправляли четырьмя попытками за две ночи. Первый фикс: ожидание завершения индексации. Не помогло, потому что функция не возвращала нужный идентификатор. Второй фикс: возврат идентификатора. Не помогло, потому что запрос данных уходил раньше, чем приходил ответ. Третий фикс: синхронизация порядка запросов. Баг исчез, но причина осталась неясной. Четвёртый фикс: добавление диагностики и логирования. Только тогда мы по-настоящему поняли, что происходило.
На следующее утро, уже на свежую голову, мы написали три технических отчёта общим объёмом в три тысячи строк. Документация по воронке, по базе знаний, по тестовому чату. Это не было запланировано: просто после двух ночей отладки захотелось записать, как всё устроено, пока помнишь. Те отчёты потом пригодились не раз.
Иллюзия авторизации и бесконечный цикл

Ещё один параллельный фронт: авторизация. Пользователь выходит из системы, но вместо перехода на страницу логина попадает в бесконечный цикл. Причина: одновременно срабатывали два процесса. Браузер пытался обновить токен, а прокси-слой уже удалил старый. Каждый из них считал, что пользователь авторизован, и перекидывал его друг на друга.
Исправление заняло целый день: переписали клиентскую библиотеку, добавили различение типов ошибок (401 от прокси и 401 от сервера требуют разных действий), пересобрали логику охранников маршрутов. Шесть коммитов за восемь часов.
Утечка через прокси: смешанные заголовки

В воскресенье наш разработчик провёл двухчасовой марафон отладки интеграции с внешними CRM-системами. Восемь коммитов подряд, каждый добавлял логирование, сужая область поиска.
Проблема оказалась элегантной и неприятной: HTTP-клиент переиспользовался между запросами к разным аккаунтам. Заголовок авторизации от одного клиента попадал в запрос другого. Данные утекают не к злоумышленнику, а к соседнему клиенту платформы, что ничуть не лучше.
Решение: создавать новое соединение для каждого модуля, не переиспользовать клиенты. Безопасность важнее микро-производительности.
Парадигма угроз: 1 большая против 4 маленьких

Что опаснее: одна большая уязвимость или четыре маленьких? Четыре маленьких. Большую дыру видят все, чинят в аврале. Мелкие живут месяцами, притворяясь edge cases. Пустой параметр? Ну, это же редкий случай. Логин без ошибки? Ну, токен же не выдаётся. IDOR в одном из семнадцати сервисов? Ну, кто будет подбирать идентификаторы.
Проблема в том, что атакующий не выбирает одну уязвимость. Он комбинирует эти безобидные «микро-трещины» в цепь фатальной атаки.
Фреймворк недели: «Чек-лист новой двери»
209 коммитов за неделю. CRM-модуль, база знаний, авторизация, интеграции. И четыре уязвимости, три из которых объединяет один паттерн: новый маршрут создавался без проверки, кто имеет к нему доступ.

После этой недели у нас появилось правило: каждый раз, когда мы создаём новый эндпоинт, новую страницу или новый API-метод, задаём четыре вопроса. Если хотя бы на один вопрос нет ответа в коде (не в голове разработчика, а в коде), маршрут не проходит ревью.

Проверьте у себя: откройте последний pull request, в котором добавлялся новый API-эндпоинт. Есть ли в нём проверка принадлежности данных? Что произойдёт, если передать пустой идентификатор? А идентификатор другого пользователя? Если эти проверки зашиты в общий базовый класс, отлично. Если каждый эндпоинт проверяет (или не проверяет) по-своему, у вас та же проблема, что была у нас.
В выпуске про неделю 132 я рассказывал про «дверь без замка»: баг, который показывал чужие данные. Тот баг был другим по природе, но из той же категории. Неделя 123 научила нас ставить замки. Неделя 132 показала, что один мы всё-таки пропустили. Безопасность не бывает «готовой». Она бывает только «проверенной на этой неделе».

Как обнаружить гонку состояний в интерфейсе?
Симптом: данные иногда загружаются, иногда нет. Перезагрузка страницы «чинит» проблему. Причина: два асинхронных запроса зависят друг от друга, но выполняются параллельно. Кто финиширует первым, тот и определяет результат. Диагностика: добавьте логирование с временными метками к каждому запросу. Если запрос «Б» завершается раньше запроса «А», от которого зависит, у вас гонка. Лечение: выстроить запросы в цепочку, где каждый следующий ждёт результата предыдущего.
Зачем удалять 8 000 строк кода за один коммит?
Потому что поэтапное удаление потребовало бы поддерживать две навигации одновременно: старую (отдельные страницы) и новую (CRM-модуль). Это месяц двойной работы и двойных багов. Один большой коммит рискованнее, но честнее: либо работает всё, либо видно сразу, что сломалось. Острая боль на одну ночь вместо тупой боли на месяц.
Что опаснее для SaaS: одна большая уязвимость или четыре маленьких?
Четыре маленьких. Одну большую заметят быстро: она очевидна, про неё напишут, её починят в авральном режиме. Четыре маленьких могут жить месяцами, потому что каждая по отдельности выглядит «не критичной». Пустой параметр? Edge case. Логин без ошибки? Токен же не выдаётся. Подмена идентификатора? Кто будет подбирать. Проблема в том, что атакующий не выбирает одну уязвимость. Он комбинирует.
Что такое IDOR и как от него защититься?
IDOR (Insecure Direct Object Reference): пользователь получает доступ к чужим данным, подменив идентификатор в запросе. Защита: централизованный базовый класс, который проверяет принадлежность данных автоматически. Каждый новый сервис наследуется от него и получает проверку «бесплатно». Семнадцать индивидуальных проверок заменяются одним турникетом на входе.



