Неделя 132. Дверь без замка: баг, который показывал чужие данные, и спортзал, въехавший в CRM за день

Разбор этой статьи
Эту тему разобрали в подкасте. Слушай параллельно с чтением.
Самые страшные баги не роняют систему. Она продолжает работать: быстро, стабильно и неправильно. Во вторник, в одиннадцатом часу вечера, я закоммитил фикс из семи строк с пометкой SECURITY. Семь строк закрывали дыру, через которую пользователь с пустым списком прав мог увидеть данные всех компаний на платформе. Без взлома, без хитрой атаки. Из-за одного условия «если», написанного с лучшими намерениями.
Я Дмитрий Дьяконов, основатель Botseller AI. Это восьмой выпуск серии «Ретроспектива», бортжурнал в прошлое: от настоящего к первому коммиту. В выпуске о неделе 133 я разбирал семь тысяч строк CRM за одну пятницу и день, когда платформа получила своё имя. Сегодня отматываю ещё на неделю назад, на 9-15 февраля 2026 года. У этой серии есть привилегия, которой нет у обычного дневника: я знаю, чем всё закончилось. Знаю, что семь строк того фикса переживут все последующие рефакторинги. Знаю, что спортзал, въехавший в CRM в понедельник, станет вторым шаблоном из многих. И знаю, что красивый интерфейс рассылок, собранный за день на выдуманных данных, через две недели встретится с реальностью, и встреча будет непростой.
Контекст: что было к началу недели

Февраль 2026 года. Ботам на платформе чуть больше полугода: они отвечают клиентам в мессенджерах, у бизнеса есть личный кабинет, деньги списываются за диалоги. CRM внутри платформы уже существует, но живёт без нормального интерфейса: своё лицо она получит только в следующую пятницу, и об этом был прошлый выпуск. Первые клиенты приходят из офлайновых ниш: детские центры, фитнес. Блог недавно переехал со старого движка, и это переезд с последствиями, о которых чуть ниже. Команда крошечная, и почти каждая история этой недели началась со слов живого клиента.
Понедельник: спортзал въезжает в CRM за один день

В понедельник вечером, в 18:40, в CRM появился фитнес-клуб. Не конкретный клуб, а целая ниша: шаблон «спортзал» с посетителями, абонементами, расписанием и тренерами.
История простая и, думаю, знакомая каждому, кто делает продукт для бизнеса. Приходит клиент из новой отрасли и просит вроде бы то же самое, но другими словами. У детского центра были родители, дети и занятия. У фитнес-студии посетители, абонементы и тренировки. Сущности разные, механика одна: люди записываются на занятия по расписанию, у них есть оплаченный лимит посещений, и бизнесу нужно видеть, кто пришёл, кто пропал и у кого заканчивается абонемент.
Здесь была развилка, на которой ломаются многие продукты. Вариант первый: сделать отдельную доработку под клиента. Быстро, клиент доволен, а через год у тебя пять несовместимых версий продукта и страх что-либо трогать. Вариант второй: сказать «наш продукт так не умеет» и потерять клиента. Мы выбрали третий: превратить запрос в шаблон. Одна CRM, один код, а ниша определяет только названия сущностей, набор полей и стартовую настройку. Понедельничный коммит так и выглядит: связь абонементов с конкретными услугами, чтобы абонемент «восемь занятий йогой» знал, что он про йогу, а не про массаж. Безлимитный абонемент мы сделали самым честным способом из возможных: у него просто нет счётчика.

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

Вторник начался с жалобы, которую слышал, наверное, каждый сервис на свете: «пароль точно верный, войти не могу». Разгадка была унизительно простой. Форма входа ждала логин, а люди вводили почту. С их точки зрения всё логично: почта это и есть их имя в интернете. С точки зрения системы это был неверный идентификатор, и она честно отказывала.
Можно было написать подсказку «введите логин, а не почту». Мы сделали иначе: в 14:48 форма входа научилась принимать и то и другое, тремя синхронными коммитами в трёх сервисах. Урок дешёвый, но я его проговорю, потому что он универсальный: если пользователи массово ошибаются в одном и том же месте, чинить надо не пользователей, а место. Инструкция это заплатка на интерфейсе, который спорит с привычками людей. Привычки всегда победят.
Вторник днём, часть вторая: витрина рассылок без магазина

Через шесть минут после фикса входа, в 14:54, лёг коммит на сорок пять файлов: модуль рассылок. Визард на четыре шага, шаблоны сообщений с категориями и статусами модерации, живой предпросмотр прямо в нарисованной рамке телефона: заполняешь поля, а справа видишь, как сообщение будет выглядеть у клиента в чате. Вечером того же дня превью доросло до выезжающей панели, которая остаётся на экране при прокрутке.
Секрет этого модуля в том, чего в нём не было. Бэкенда. Ни одной строки. Все кампании и шаблоны в нём были выдуманными, и в коммите об этом написано прямым текстом: данные заглушки, на реальные переключим позже. Мы построили витрину до магазина, и это было осознанное решение: пощупать сценарий руками, показать людям, поймать все неловкости на макете. Менять картинку дёшево. Менять контракты между сервисами дорого.
Зная будущее, могу сказать, чем это закончилось. Настоящий бэкенд рассылок придёт через две недели, ночным коммитом на восемь тысяч строк, и я разбирал эту сагу в выпуске о неделе 134. Там же выяснится, что у витрины и магазина есть шов: эндпоинт статусов, который интерфейс подразумевал, а бэкенд не реализовал. Подход «сначала интерфейс» я по-прежнему считаю правильным, но с поправкой: выдуманные данные должны с первого дня жить в форме будущего контракта. Иначе шов разойдётся именно там, где его не проверяли. А про сам официальный канал, вокруг которого всё это строилось, у нас есть отдельный разбор: как подключить WhatsApp Business API.
Вторник, 22:59: пустота, которая открывала всё

Теперь главная история недели. Днём во вторник мы починили мелочь: список тренеров в расписании не учитывал, какой компании они принадлежат. Поправили фильтр, поехали дальше. Но у этой мелочи оказалось продолжение, и вечером я сидел над кодом доступа к данным с очень неприятным ощущением.
У каждого пользователя платформы есть список компаний, данные которых ему разрешено видеть. Проверка доступа выглядела разумно: если список не пуст, отфильтруй данные по нему. Читается нормально, ревью проходит, тесты зелёные. Вопрос, который никто не задал: а что происходит, когда список пуст? Ответ: условие не срабатывает, фильтр не применяется, и запрос уходит в базу без ограничений. Пустой список прав молча превращался в полный доступ. Дверь без замка: снаружи выглядит как дверь, но не заперта.

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

Если у вас сервис, где данные разных клиентов лежат в одной базе, вот проверка на один вечер. Первое: заведите тестового пользователя, у которого нет ни одного права, и посмотрите на систему его глазами. Он должен видеть пустоту, а не вселенную. Второе: поищите в коде условия вида «если фильтр задан, применить». Каждое из них это потенциальная дверь без замка, потому что отсутствие фильтра там означает отсутствие ограничений. Третье: переверните принцип. Безопасное поведение по умолчанию это «не показывать», а фильтр должен расширять доступ, а не сужать его. В индустрии это называют fail closed: при сомнении система закрывается, а не открывается.
Четверг: отладка вслепую и выключатель, который опоздал на день

Четверг принёс историю про смирение. Один из клиентов ведёт продажи в Битрикс24, и наша платформа создаёт там лиды из переписок. Понадобилась обратная операция: если лид удаляют у нас, он должен корректно исчезать и в Битрикс24. Задача звучит на полчаса, но есть нюанс: чужая система это чёрный ящик. Ты не видишь её логику, не можешь поставить точку останова, а документация описывает мир, который местами расходится с реальностью.
Хроника четверга по коммитам выглядит как честный дневник: в 11:56 «добавил лог, чтобы разобраться в ситуации», в 12:22 «добавил ещё логов, чтобы увидеть результат», в 12:51 сама логика удаления. Час работы: два коммита глаз, один коммит рук. Я привожу эту последовательность, потому что она и есть метод. При интеграции с системой, которую вы не контролируете, первым делом стройте видимость: логируйте, что вы отправляете и что вам отвечают. Код, написанный до того, как вы это увидели, это код по мотивам документации, а не по мотивам реальности. Если вам предстоит похожая связка, у нас есть подробный разбор интеграции ИИ-бота с Битрикс24.

Тем же утром, в пять с копейками, у сценариев бота появился выключатель. До этого «поставить бота на паузу» означало «удалить сценарий»: клиент, который хотел притормозить бота на праздники, сталкивался с выбором между «работает» и «не существует». Сам выключатель занял смешные три строки плюс поле в базе. А назавтра выяснилось то, что выяснилось: автоматические дожимы, напоминания клиентам, которые замолчали, про выключатель не знали и продолжали работать. Выключенный бот молчал в диалогах, но бодро дожимал. Пятничный коммит научил дожимы уважать выключатель. Урок на полях: у любой фичи с состоянием «выключено» есть хвост из фоновых процессов, и каждый из них нужно знакомить с этим состоянием отдельно. Через неделю, кстати, выключатель дорастёт от сценариев до самих ботов, во всех каналах разом: эта история есть в выпуске о неделе 133.
Пятница тринадцатого: генеральная уборка сайта

Всю неделю фоном, а в пятницу тринадцатого февраля в полный рост, шла генеральная уборка сайта. Наш блог незадолго до этого переехал со старого движка, и наследство оказалось богатым. Вебмастерская панель показывала ровно сто битых ссылок. Страницы категорий отдавали редирект вместо содержимого. Пагинация местами вела на адреса с удвоенным «blog». А первый экран главной страницы на телефоне появлялся за пять секунд.
Разбор битых ссылок мне особенно дорог. Девяносто два адреса из ста уже перенаправлялись корректно. Семь не работали из-за кодировки: браузеры и поисковики отправляют кириллические адреса в закодированном виде, а наши правила редиректов ждали кириллицу как есть, и старые русскоязычные адреса статей уходили в 404. Ещё один адрес перехватывался не тем сервисом из-за слишком жадного правила маршрутизации. Итог: сто битых ссылок закрылись правкой в пять строк. Со скоростью вышло похоже: анимация первого экрана начиналась с невидимого текста, и браузер считал страницу пустой дольше, чем она была; крошечный логотип ехал с отдельного домена и стоил почти секунду на сетевые рукопожатия. После фикса первый экран стал появляться примерно за две с половиной секунды.
Если у вас сайт после переезда или редизайна «есть, но не находится», начните не с контента, а с сантехники: битые ссылки в вебмастерской панели, редиректы со старых адресов, скорость первого экрана на телефоне. В нашем случае почти весь ущерб наносили механические поломки с копеечными фиксами.
А теперь ложка хайндсайта. В ту же пятницу мы аккуратно разложили по десяти категориям 597 статей, доставшихся от старого сайта, и построили для них перелинковку и навигацию. Через полтора месяца мы удалим большинство этих статей за ненадобностью: эта чистка описана в выпуске о неделе 138. Порядок в мёртвом контенте это всё ещё мёртвый контент. Сантехника была правильной инвестицией, категоризация нет. Жаль, что понять это заранее у меня тогда не получилось.
Воскресенье, 22:09: последний коммит недели

Неделя закончилась в воскресенье в 22:09 коммитом, который формально принадлежит ей, а по смыслу открывает следующую. За сутки до этого очередь сообщений выросла с обычных пяти тысяч до семидесяти, и воскресный вечер ушёл на разбор и фиксы: повторные попытки, которые перемножались между слоями системы, платёжные уведомления без защиты от дублей, фоновая синхронизация без дедупликации. Подробный разбор этого инцидента, с механикой и выводами, я вынес в начало выпуска о неделе 133, так что здесь только зафиксирую симметрию: неделя, начавшаяся с чужого спортзала, закончилась собственным марафоном.
Урок недели: по умолчанию система должна говорить «нет»

Каждый выпуск я стараюсь заканчивать одним уроком, который пережил свою неделю. Урок недели 132 собрался из трёх её историй, на вид не связанных.
Пустой список прав открывал все данные, потому что поведением по умолчанию было «показать». Выключенный сценарий продолжал дожимать клиентов, потому что поведением дожимов по умолчанию было «работать». И даже форма входа отвергала законную почту, потому что по умолчанию считала правым себя, а не человека.
С той недели я проверяю любую новую логику одним вопросом: что произойдёт в самом бедном случае? Когда список пуст, права не выданы, флаг не установлен, зависимость молчит. Если в этом случае система делает что-то щедрое: показывает, отправляет, списывает, разрешает, значит где-то заложена мина. Безопасное умолчание скучное: не показывать, не отправлять, не списывать. Все расширения доступа должны быть явными. Это правило не добавляет ни одной фичи, зато однажды тихо спасает бизнес, и вы даже не узнаете дату, когда это произошло.
Цифры недели
- 62 коммита в семи проектах за семь дней
- Около 20 тысяч добавленных строк
- 7 строк: фикс самого опасного бага недели
- 45 файлов: интерфейс рассылок, собранный целиком на выдуманных данных
- 100 битых ссылок закрыты правкой в 5 строк
- 597 статей старого блога разложены по 10 категориям (спойлер: зря)
- Первый экран сайта на телефоне: с 5 до примерно 2.5 секунд
- Синхронизация подписок: с 70 до 5-10 секунд
- 1 новый шаблон CRM: фитнес, второй после детских центров
Что было дальше

Дальше была неделя 133: интерфейс CRM на семь тысяч строк за одну пятницу, суббота, заложившая фундамент официального канала WhatsApp, и день, когда платформа перестала называться рабочим именем и стала Botseller. Витрина рассылок, построенная в этот вторник на выдуманных данных, через две недели получит настоящий бэкенд, и об этом был выпуск о неделе 134. А правило «по умолчанию нет» переживёт все описанные фичи и станет частью того, как мы проектируем платформу до сих пор.
Все выпуски серии собраны в категории ретроспектива, а текущая хроника разработки продолжается в бортовом журнале.
FAQ
Что такое ретроспектива Botseller?
Серия «Ретроспектива», бортжурнал в прошлое: от настоящего к первому коммиту. Каждый выпуск разбирает одну неделю разработки платформы по реальным git-логам: решения, ошибки и уроки от первого лица основателя. Оптика обратная: события описываются с уже известным финалом. Все выпуски собраны в категории ретроспектива.
Как проверить, что пользователи сервиса не видят чужие данные?
Самый быстрый способ: создать тестового пользователя без единого права и посмотреть на систему его глазами. Он должен видеть пустые списки, а не все данные подряд. Дальше стоит найти в коде все условия вида «если фильтр задан, применить» и убедиться, что пустой фильтр означает пустой результат, а не отсутствие ограничений. Общий принцип называется fail closed: при любой неоднозначности система закрывает доступ, а не открывает.
Почему пользователь не может войти, хотя пароль верный?
Частая причина: система ждёт один идентификатор, а человек вводит другой. Например, форма принимает только логин, а пользователь вводит почту, с которой регистрировался. Если такие ошибки массовые, надёжнее не воспитывать пользователей подсказками, а принимать оба идентификатора: и логин, и почту. Привычки людей всегда сильнее инструкции под полем ввода.
Может ли одна CRM подойти и фитнес-клубу, и детскому центру?
Да, если она построена на шаблонах, а не на жёстко зашитых сущностях. Механика у таких бизнесов совпадает: клиенты записываются на занятия по расписанию, есть лимиты посещений и абонементы. Различаются названия сущностей и набор полей, и именно это выносится в шаблон ниши. Как это устроено у нас, можно посмотреть в статье про простую CRM для малого бизнеса.
Как ИИ-бот дружит с Битрикс24?
Платформа создаёт лиды в Битрикс24 из переписок бота с клиентами и синхронизирует их дальнейшую судьбу, включая удаление. Главная сложность таких интеграций не в коде, а в видимости: чужая система это чёрный ящик, поэтому сначала строится логирование запросов и ответов, и только потом пишется логика. Подробный разбор есть в статье об интеграции ИИ-бота с Битрикс24.
Почему после переезда сайта падает трафик из поиска?
Чаще всего из-за механических поломок, а не из-за контента. Старые адреса перестают отвечать: особенно коварны кириллические URL, которые поисковики запрашивают в закодированном виде. Страницы категорий и пагинация ломаются из-за слишком жадных правил маршрутизации. Скорость первого экрана на телефоне проседает из-за анимаций и лишних доменов. Диагноз ставится по панели вебмастера: список битых ссылок там обычно указывает прямо на причину.



