История о том, как я перестал добавлять новые функции и начал строить систему

После моей первой статьи про Pulse я не ожидал, честно говоря, примерно ничего. Но к моему удивлению увидел реакцию: немного поддержки, немного критики, несколько советов по UX.

Pulse
Pulse

Я получил около 7 тысяч просмотров, десятки комментариев и, неожиданно для себя, полноценный аудит проекта. Особенно, просто золотой грааль - комментарий от пользователя domix32. Он не обсуждал идеи. Не спорил про дизайн. Не рассуждал о будущем мессенджеров или самого Пульса. Он просто взял и проверил его на прочность.

И нашёл проблемы.

На момент начала аудита в Pulse уже было:

— 172 зарегистрированных пользователя;
— более 1100 сообщений (и нет, не все мои, а всего лишь 20%);
— около 60 пользователей, реально отправлявших сообщения;
— Если кто-то вдруг еще не в курсе - то так же имеются группы, приглашения, восстановление доступа, вход по устройству и PWA.

То есть речь уже шла не о прототипе, который я друзьям показал, мы с ним поигрались и все. А о системе, которой, на мое удивление, начали пользоваться реальные люди. Похоже тот самый хабрэффект сработал.

Когда критика полезнее похвалы

В комментарии было несколько конкретных замечаний:

  • не работает связь с разработчиком;

  • странно работает валидация меток;

  • есть вопросы к юридическим страницам;

  • уведомления между сессиями синхронизируются не идеально;

  • безопасность вызывает вопросы.

Но одна фраза запомнилась больше остальных:

За прочую безопасность даже не пробовал тестировать.

На первый взгляд обычный комментарий. Мысленно я очень сильно зацепился за именно эту часть комментария. Потому что она с одной стороны - короткая, но чертовски емкая. И формулировка внутри меня звучала уже так:

«Я уже нашёл достаточно. Если копнуть глубже — найду ещё.»

Именно после этого я остановил работу над новыми фичами и принял решение вместо очередного релиза с красивыми возможностями и двигаться в сторону безопасности, потому что сейчас публикуясь уже на Хабре, да и вообще привлекая новых пользователей в Пульс - этот вопрос встает очень и очень остро. Я закинул снапшот проекта чату GPT, с просьбой провести полный аудит, опираясь на комментарий и дополнительно проверить возможные дыры, баги и прочие дефекты проекта. Сколько же хлама в нем оказалось, но увы это последствия ранней архитектуры и вайбкодинга, так как все писалось с нуля с бешенным рвением, пусть и старался вести политику "реализуем исключительно правильные архитектурные решения". Но... Меня это от рефакторинга увы не спасло. В целом я этого ожидал.

Неожиданный результат аудита

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

Например:

Сначала мы исправили доступ к диалогам. После этого стало сильно бросаться в глаза, что авторизация разбросана по нескольким обработчикам и требует отдельного AuthService. Когда начали выносить AuthService, выяснилось, что публичные и приватные DTO смешаны между собой. После разделения DTO стало заметно, что часть публичных endpoint вообще не ограничена по частоте запросов. После внедрения Rate Limiter обнаружились проблемы жизненного цикла WebSocket-соединений. А после ревизии WebSocket стало понятно, что отдельного внимания требует транспортный уровень и работа с origin-политиками. В какой-то момент стало ясно: это уже не набор отдельных багов.

Я прекрасно понимал, что все это последствия ранних архитектурных решений, которые были абсолютно нормальными для небольшого проекта, но начали создавать риски по мере роста системы уже на текущем этапе.

Поэтому вместо хаотичных исправлений появился технический roadmap. Не список новых функций. А список инженерных задач, которые постепенно устраняют накопленные архитектурные компромиссы.

Что именно предстоит исправить

Visibility-aware WebSocket Lifecycle

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

Задача — сделать жизненный цикл WebSocket зависимым от состояния вкладки и активности пользователя.

Auth Hardening

Текущая система авторизации уже работает, но содержит упрощения, допустимые для ранних версий продукта. Предстоит перевести генерацию кодов на криптографически стойкие источники случайности, сделать атомарный claim запросов входа и усилить защиту сценариев восстановления доступа.
(Часть задач из первоначального roadmap уже была закрыта во время подготовки статьи. В частности, релиз 0.9.49 принёс AuthService Foundation и DB-based Rate Limiter. Поэтому следующий этап посвящён уже не построению базовых механизмов, а дальнейшему усилению безопасности существующей системы.)

Media Safety

Pulse использует отдельный микросервис обработки медиа. На раннем этапе основное внимание уделялось функциональности.

Теперь пришло время заниматься безопасностью загрузок:

  • ограничением размеров файлов;

  • проверкой MIME-типов;

  • защитой от SVG-атак;

  • контролем потребления памяти при обработке изображений.

Message History & Realtime Stability

История сообщений и realtime-события сегодня работают, но по мере роста объёма данных начинают проявляться эффекты гонок, устаревших ответов и пограничных состояний.

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

Transport Security

На текущем этапе соединения уже работают через HTTPS и WSS. Следующий шаг — ужесточение транспортной безопасности:

  • проверка origin;

  • отказ от передачи чувствительных данных через query-параметры;

  • CSP и дополнительные защитные заголовки;

  • дальнейшая изоляция внутренних сервисов.

Security UX

Самая недооценённая часть безопасности — прозрачность для пользователя.

Появятся:

  • события безопасности;

  • дополнительные инструменты контроля доступа к аккаунту.

Пользователь должен понимать, что происходит с его учётной записью, а не просто доверять системе на слово.

Проблема №1. Историю чужого диалога нельзя защищать фронтендом

Одна из первых вещей, которую мы пересмотрели — доступ к истории сообщений.

История загружалась по conversation_id.

Условно:

GET /history?conversation_id=...

При этом сама архитектура предполагала, что фронтенд покажет пользователю только те conversation_id, которые ему принадлежат. Это удобное предположение.

И очень опасное.Если безопасность держится на том, что клиент «не знает идентификатор», значит безопасности фактически нет.

Решение

В релизе 0.9.48 появился ConversationAccessService. Теперь любой запрос к данным диалога проходит через одну точку проверки:

CanAccessConversation(userID, conversationID)

Если пользователь не является участником диалога или группы — получает:

403 Forbidden

Причём эта проверка используется не только для истории сообщений.

Через неё теперь проходят:

  • история сообщений;

  • закрепления;

  • скрытие диалогов;

  • mute-состояния;

  • часть realtime-операций.

Фактически появился единый слой авторизации доступа к данным. Что с точки зрения безопасности мне кажется вполне правильным.

Проблема №2. Email не должен утекать через публичные API

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

Публичные методы вроде:

/users
/users/search
/users/by-alias

могли вернуть больше информации, чем реально требовалось клиенту, но гораздо информативнее и полезнее человеку, который решил изучить API продукта чуток внимательнее и глубже. В том числе как оказалось в выдачу попадал и чувствительный email.

Решение

Разделить публичные и приватные данные по разным моделям. Модель была разделена на:

PublicUserDTO
PrivateUserDTO

Теперь публичный API возвращает только действительно публичную информацию:

{
  "id": "...",
  "username": "...",
  "alias": "..."
}

Email остаётся доступным только владельцу профиля. На первый взгляд изменение маленькое. На практике именно из таких мелочей обычно и состоят настоящие утечки данных, которые потом радостно продаются, сливаются теми самыми взломщиками.

Проблема №3. Rate Limiter, который сначала не работал

Следующим шагом стал релиз 0.9.49.

Лимиты на вход, лимиты там и сям. Кто их любит? Из пользователей никто. А вот их отсутствие очень любят ломатели софта, так как это дает им неограниченные возможности для создания неограниченного количества запросов там, где надо бы их ограничивать. Отсюда появился DB-based Rate Limiter. И тут произошло самое интересное. Мы были уверены, что всё работает. Пока не начали тестировать.

Что пошло не так

В качестве идентификатора клиента использовался адрес подключения, который идет на бек с фронта. То есть фактически не реальный адрес пользователя, а наш собственный nginx. В логах это выглядело примерно так:

127.0.0.1:53120
127.0.0.1:53121
127.0.0.1:53122

Проблема оказалась в том, что порт менялся на каждом запросе. Для лимитера это были разные пользователи. Количество попыток никогда не достигало лимита. То есть защита существовала только на бумаге.

Решение

Пришлось разобрать следующее. Нынешний Reverse proxy был настроен, но не до конца. Как итог перелопатили:

  • работу nginx;

  • X-Real-IP;

  • X-Forwarded-For;

  • алгоритмы fixed window.

В результате мы переписали лимитер, и теперь ограничения работают для:

  • входа через устройство;

  • восстановления доступа;

  • поиска пользователей.

Во время тестирования запросов подряд, выходящих за пределы установленного лимита, теперь корректно получаем честный стоп:

429 Too Many Requests

Именно тогда стало понятно, что защита действительно работает и этот гештальт можно закрыть.

Проблема №4. Логи тоже являются поверхностью атаки

Ещё один интересный момент обнаружился во время ревизии. В логах присутствовали данные, которые не должны были туда попадать:

  • токены - я даже ужаснулся, что допустил такое;

  • части WebSocket payload;

  • некоторые служебные данные.

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

Чем больше пользователей и инфраструктуры появляется, тем опаснее становятся собственные логи, ведь есть любители заглянуть на вкладки в консоль и сеть :)

В результате была проведена отдельная чистка логирования. Теперь чувствительные данные в логах отсутствуют.

Что изменилось в подходе

Самое интересное произошло не в коде. Изменился подход к разработке. Раньше цикл выглядел так:

идея → фича → релиз

Теперь он выглядит иначе:

идея → безопасность → архитектура → фича → релиз

Это медленнее.

Но эффективнее. Причем закрывает дыры в продукте раньше, чем их находят пользователи. И на мой скромный взгляд именно так хобби-проект постепенно превращается в систему.

Цена ранних решений

Самое интересное, что ни одна из найденных проблем не была результатом плохого решения. Почти все они были результатом правильных решений, принятых слишком рано или слишком поздно. Когда в системе 2 пользователя, централизованный сервис доступа кажется избыточным. Когда в системе нет восстановления доступа, кажется, что отдельный AuthService не нужен. Когда проектом пользуешься только ты сам и несколько твоих знакомых, друзей, то отсутствие Rate Limiter не кажется проблемой.

Но каждая такая временная упрощённая конструкция со временем превращается в технический долг. Именно поэтому большая часть работы последних дней (да-да именно дней) была не про новые функции, а про возврат архитектурного контроля над системой.

Что дальше

После аудита появилась отдельная дорожная карта технических релизов.

Следующие этапы:

  • Visibility-aware WebSocket Lifecycle;

  • Media Safety;

  • Message History Stability;

  • Transport Security;

  • Security UX.

Часть задач вообще не видна пользователю и функционально никак не ощущается. Но именно они определяют, выдержит ли проект рост и атаки на него.

Итог

Я точно знаю, что после публикации первой статьи проект стал лучше. Не потому что его похвалили. А потому что его раскритиковали. Иногда один технический комментарий даёт больше пользы, чем сотня лайков.

Поэтому если у вас есть собственный проект — покажите его людям. Особенно тем, кто способен найти в нём проблемы. Они могут оказаться лучшими помощниками в развитии продукта.

Спасибо всем, кто тестирует Pulse, пишет замечания и не боится критиковать.

И отдельное спасибо domix32. Некоторые архитектурные решения в проекте появились именно благодаря вашему комментарию.

Спасибо, что дочитали до этого места. Я открыт для любых замечаний, комментариев и пожеланий. А всем остальным желаю удачи, простых решений и легкого кодинга в своих проектах. Буду рад почитать ваши решения!