Настольный клиент к БД предъявляет два требования, которые обычно нигде не записаны. Первое: с каждого ноутбука разработчика должна быть сетевая достижимость до продовой сети. Второе: на каждом ноутбуке должна лежать копия всех учётных данных. Оба требования несущие, и именно из-за них клиент к базе оказывается последним инструментом, который команда заводит за SSO.
Если перенести сам клиент на сервер рядом с данными, оба требования исчезают. Пароль перестаёт жить на шестидесяти ноутбуках и живёт в одном месте. Доступ по сети перестаёт быть выдачей VPN на человека и становится вопросом топологии развёртывания: до базы ходит один хост, до этого хоста ходят люди, каждый со своей ролью.
Размен того стоит, но он не бесплатный. Появляются четыре проблемы, которых у настольного клиента не было, потому что клиент теперь многопользовательский сетевой сервис, держащий все подключения команды. Мы прошли через все четыре, пока делали LibreDB Studio, самостоятельно размещаемую IDE для баз данных, работающую в браузере. Ниже конкретная форма каждой проблемы, включая две, которые мы сначала решили неправильно.
Что это в терминах установки
Это обычный сервис на своём железе, без внешних зависимостей и без обращения куда-либо наружу:
- .deb и .rpm со своим приватным Node внутри, юнит systemd, никаких требований к системному node;
- AppImage и Snap, если нужно поднять на рабочей станции или на машине без пакетного репозитория;
- tarball для linux amd64 и arm64;
- образ ghcr.io/libredb/libredb-studio и Helm chart с оператором, если хозяйство уже в Kubernetes;
- npm-пакет, если удобнее запускать своим рантаймом.
Состояние (подключения, вкладки, история запросов) хранится либо в файлах, либо в SQLite, либо в PostgreSQL, выбирается одной переменной окружения. Аутентификация: локальные пароли или OIDC через PKCE. Лицензия MIT, сборки воспроизводятся из тега, к релизу прикладывается SBOM.
Дальше про то, что ломается при переносе.
1. Граница доверия переезжает внутрь вашего же процесса
На ноутбуке границей авторизации является пользователь операционной системы. На сервере все подключения держит один процесс, и кто-то внутри этого процесса должен решать, кто именно спрашивает.
Очевидный ответ: аутентифицирующий край, то есть middleware, которое отрабатывает перед каждым маршрутом, проверяет cookie сессии и заворачивает остальных. Такое у нас есть. Стоит проговорить вслух другое: это не граница.
Middleware в src/proxy.ts действительно заворачивает неаутентифицированные запросы, но это оптимизация, а не граница авторизации. Дырка в матчере (матчер освобождает любой путь, в котором есть точка) или обход на уровне фреймворка открывают все маршруты, которые полагаются только на него.
Поэтому каждый маршрут, который дотягивается до базы или до провайдера модели, проверяет вызывающего повторно, через один общий guard. Край существует, чтобы типовой случай был дешёвым, а не чтобы ему доверяли.
Разница становится видна в первый же момент, когда легитимный вызов не может предъявить сессию. У нашего агентного рантайма есть колбэк, который просит сервер подхватить долгоиграющую сессию запроса. Вызывающая сторона это транспорт, а не человек, и cookie у неё нет по построению. Соблазнительное решение: добавить путь в список публичных. Оно неверно структурно, потому что исключение по пути имеет форму пути, а значит внутрь попадает всё, что смогло достучаться до порта.
Вместо этого вызывающая сторона предъявляет удостоверение, которое сервер выпустил сам: годное одну минуту, называющее ровно один запуск, не дающее ничего кроме продолжения этого запуска, и подписанное ключом, производным от JWT-секрета, а не самим JWT-секретом. Это не сессия, и превратить его в сессию нельзя. Middleware пускает такой путь только при валидном удостоверении, а маршрут всё равно проверяет его второй раз.
2. CSRF становится настоящим, а обратный прокси молча ломает защиту
У настольного клиента нет cookie и нет фоновых полномочий. У браузерного есть и то и другое, поэтому изменяющим состояние запросам нужен второй слой поверх сессионной cookie. Мы сравниваем Origin запроса с собственным хостом развёртывания.
Два решения внутри этой проверки стоит скопировать, и оба они не про атакующего, а про ложные срабатывания.
Сравнение только по хосту, схема игнорируется. Прокси, который терминирует TLS и передаёт дальше обычный HTTP, не выставив x-forwarded-proto, приводит к тому, что браузер шлёт Origin: https://db.example.com, а приложение вычисляет http://db.example.com. Сравнение схем в этом месте запирает администратора снаружи его собственной формы логина. Мы теряем защиту от страницы по http на том же хосте, постящей в приложение по https, а для этого нужен активный сетевой атакующий, который уже сломал транспорт. Это меньшая угроза, чем целый класс развёртываний, который иначе перестаёт работать.
Запрос без Origin и без Referer принимается, но только если content type равен application/json. Выглядит как выключатель, протащенный под чужим именем. Это не так, по двум независимым причинам. HTML-форма умеет отправляться только как x-www-form-urlencoded, multipart/form-data или text/plain, потому что четвёртого значения у enctype нет, то есть классический вектор CSRF структурно неспособен породить такую форму запроса. А межсайтовый fetch() такой content type выставить может, но application/json не входит в CORS-safelist, значит он вынуждает preflight, а развёртывание, которое нигде не отвечает заголовком Access-Control-Allow-*, никогда не отвечает на него утвердительно. Остаются curl и вызовы сервер-сервер, а это не CSRF: CSRF это именно ничего не подозревающий браузер, несущий полномочия, которые он не выбирал отправлять.
Теперь про ошибку, которую мы сначала выкатили. Матчер middleware исключал api/db/health, чтобы пробы балансировщика не проходили весь конвейер. По тому же пути живёт POST /api/db/health, детальная проверка конкретного подключения, закрытая сессией. Исключение пути исключило и метод, и единственным маршрутом без проверки origin оказался тот, который называется health. Исключения по пути слепы к методу, и это тот же урок, что и в первой проблеме, только в другом костюме.
Второе, чему научил этот слой: блокировка обязана уметь диагностировать сама себя. Прокси, который переписывает Host, не выставив x-forwarded-host, даёт несовпадение на каждом изменяющем состояние запросе, включая логин, и администратор видит работающую страницу, которая молча отказывает во всём. Поэтому тело ответа 403 называет починку: выставьте ALLOWED_ORIGINS в публичный origin развёртывания. Ошибка, объясняющая собственную причину, здесь ценнее короткой.
3. Логирование отказов превращается в поверхность отказа в обслуживании
Каждая ветка выше пишет строку в лог и событие в аудит. В интернете это означает, что неаутентифицированный сканер способен забить том с логами контейнера запросами, про которые он заранее знает, что они не пройдут, и вытеснить настоящие события из кольцевого буфера фиксированного размера, который читает административный интерфейс.
Поэтому отказы лимитируются. Сам отказ не лимитируется никогда, лимитируется только его запись.
Что нас удивило: ограничение количества строк не ограничивает размер строки. В строку лога попадает путь запроса и наблюдаемый хост, оба под контролем атакующего и оба произвольной длины. Лимит на количество без лимита на размер это та же атака в другом масштабе, поэтому каждое поле ещё и обрезается.
Есть и тонкость с ключом. Для отказа вида «аутентифицированный пользователь постучался в административный маршрут» лимитировать по IP неправильно: наличие токена ограничивает количество личностей, дошедших до этой ветки, а не количество запросов от каждой, и одна сессия способна долбить в цикле. Эта корзина ключуется по имени пользователя, так что ротация X-Forwarded-For лишних строк не приносит.
4. Состояние тоже переезжает, и это самая честная часть
Раньше хранилищем был браузер. Подключения, вкладки и история запросов жили в localStorage, что является правильным умолчанием для одного разработчика и бесполезно в ту же секунду, когда развёртывание делят двое. Серверный режим включается одной переменной окружения: чтение по-прежнему идёт из localStorage как из сквозного кэша, а изменения уезжают в серверное хранилище, разделённое по пользователям, с шифрованием учётных данных подключений на диске.
Что покупает это шифрование: украденный файл базы или дамп. Хранилищем секретов оно не является, потому что тот, кто может прочитать окружение сервера, может прочитать и ключ. И браузерная копия ваших учётных данных всё ещё лежит открытым текстом, потому что её шифрование потребовало бы мастер-пароля и процедуры восстановления, а это меняет то, чем является продукт. Именно из-за этого признания меры против межсайтового скриптинга у нас самые важные строки в описании модели безопасности, а не галочка.
Чего это не даёт
Перенос клиента сужает радиус поражения. Он не создаёт модель авторизации, которую вы не написали. В Studio две роли, и пользователь с ролью user по-прежнему может подключиться к любому хосту и порту и выполнить любой оператор: списки разрешённых целей и покомандные ограничения по провайдерам это осмысленное направление, и оно не реализовано. Написать об этом в документации, рядом с тем, что реализовано, оказалось важнее любой отдельно взятой меры.
Страница с описанием модели безопасности проверяется против репозитория на каждой сборке. Строка, называющая несуществующий файл или тест, который не запускается, роняет CI. Проверить, что связанный тест утверждает правду, она не может, и этот остаток мы несём сознательно, и он тоже записан.
LibreDB Studio распространяется под лицензией MIT: https://github.com/libredb/libredb-studio
Перемещено dataman из development







