Skip to main content
В Omniflow есть три вещи, ради которых его имеет смысл атаковать: доступ оператора к панели, которая двигает деньги, учётные данные клиента, открывающие оплаченную подписку, и секреты провайдеров, которые тратят средства с пополненных балансов. Эта страница описывает, как защищено каждое из них, что приложение сознательно отказывается делать и где поведение текущей сборки отличается от того, что оператор мог бы предположить. Всё изложенное — свойство кода, а не рекомендация. Там, где механизм защиты отсутствует, об этом сказано прямо.

Три поверхности учётных данных

У HTTP API ровно три префикса, и они никогда не делят между собой middleware. Объединение любых двух из них на одном префиксе означало бы, что один незаметно наследует middleware другого. Раздельность делает утверждение «учётные данные клиента не могут дотянуться до маршрута оператора» свойством таблицы маршрутов, а не проверкой, о которой кто-то должен помнить, и это подтверждает тест. admin_users полностью отделена от users. Администратор никогда не становится клиентом VPN в силу того, что у него есть аккаунт в панели, и создание такого аккаунта не порождает права на услугу в Remnawave.
APP_OPERATOR_TOKEN — это третьи учётные данные оператора: без ролей и без CSRF. Его изменения пишутся в аудит под актором operator-token, который называет токен, а не человека. При этом он обязателен для запуска API. Сгенерируйте его из криптографического источника, держите вне репозитория и вне чатов, а ротацию выполняйте правкой окружения и перезапуском. См. Интеграция с API.

Пароли операторов

Пароли хешируются argon2id и хранятся как строки в кодировке PHC, которые несут в себе собственные параметры стоимости. Запись параметров внутри хеша означает, что повышение стоимости в будущем не требует ни миграции, ни принудительного сброса: существующие хеши продолжают проверяться по своим записанным параметрам, а хеш, созданный со слабыми параметрами, незаметно обновляется при следующем успешном входе. Сбой перехеширования никогда не блокирует сам вход. Правил на состав символов нет. Длина считается в рунах, чтобы не-латинская парольная фраза не была отвергнута по ошибке. Эти значения зашиты в код и не настраиваются через окружение. Некорректный или повреждённый хеш приводит к отказу (fail closed): проверка возвращает ошибку, а не совпадение, и такой хеш считается подлежащим замене, а не заслуживающим доверия.

Защита входа

  • Устойчивость к перебору аккаунтов. Неизвестный адрес проверяется против случайно сгенерированного хеша-приманки, построенного при создании сервиса с теми же живыми параметрами, поэтому отсутствующий аккаунт стоит столько же, сколько реальный. Оператор, входящий только через OIDC и без установленного пароля, тратит ту же проверку-приманку.
  • Одна общая ошибка. Неверный пароль, неизвестный адрес и аккаунт не в статусе active — все отвечают 401 invalid_credentials. Заблокированный аккаунт отвечает 429 account_locked.
  • Блокировка. Пять неудач подряд блокируют аккаунт. Ожидание удваивается на каждой следующей неудаче, начиная с одной минуты, и ограничено одним часом, поэтому известного оператора нельзя заблокировать навсегда. Заблокированный аккаунт вообще не проверяется, поэтому подбор во время блокировки не позволяет замерить время проверки пароля.
  • Ограничение частоты запросов. 10 попыток в минуту на нормализованный адрес электронной почты и 30 в минуту на IP клиента — одно, чтобы отдельный аккаунт нельзя было измотать, второе, чтобы перебор по многим аккаунтам с одного источника тоже был ограничен. Лимит применяется до любого хеширования.
  • Успешный вход обнуляет счётчик неудач, снимает блокировку и записывает last_login_at.
Нормализация адреса только приводит регистр к нижнему и обрезает пробелы. Точки и плюс-теги сохраняются, потому что некоторые провайдеры считают их подлинно разными почтовыми ящиками.

Смена и сброс пароля

POST /v1/panel/auth/password заново проверяет текущий пароль, затем отзывает все остальные сессии, аннулирует любой невостребованный токен сброса, пишет в аудит admin.password.changed и ставит в очередь уведомление безопасности для операторов. POST /v1/panel/auth/password-reset всегда отвечает 202 Accepted без тела — независимо от того, совпал ли адрес с каким-либо аккаунтом, и в том числе при срабатывании ограничения частоты (5 в час на IP), поэтому эндпоинт нельзя использовать, чтобы выяснить, какие адреса зарегистрированы. Он не выдаёт токен. Для активного аккаунта он пишет строку аудита admin.password.reset_requested и публикует уведомление в теме 🔐 Admin security — не чаще одного раза в час на аккаунт, — чтобы владелец узнал, что кто-то об этом просил. В этой сборке нет канала, который доходит до одного оператора и ни до кого больше: нет транспорта электронной почты, а операторская группа в Telegram общая. Поэтому токен выдаётся только на сервере исполняемым файлом API, который печатает его в запустивший его терминал и больше никуда не пишет:
Команде нужны APP_DATABASE_URL и APP_DATA_ENCRYPTION_KEY, которые у контейнера API уже есть; тот, кто может её запустить, и так мог бы прочитать базу, поэтому ничего нового она не даёт. Она пишет аудит admin.password.reset_issued и публикует уведомление безопасности. Передайте токен оператору по каналу, которому доверяете; он погашает его через POST /v1/panel/auth/password-reset/complete ({"token", "newPassword"}). Токен живёт один час, одноразовый, а выдача нового отменяет предыдущий. Завершение сброса отзывает все сессии: сброс — это средство от подозрения на компрометацию, поэтому ничто, установленное до него, не должно его пережить.
Прежние сборки писали токен сброса в лог API на уровне warn, и любой, кто мог читать логи, захватывал любого оператора, включая владельца, сначала запросив сброс. Если вы куда-то отправляете логи, найдите в них admin password reset issued и считайте найденные токены раскрытыми — выдача нового токена командой выше отменяет старый. Ни у запроса, ни у завершения сброса нет экрана в /admin; оба доступны только через API.

Двухфакторная аутентификация

TOTP следует RFC 6238: SHA-1, 6 цифр, шаг 30 секунд и один шаг допуска в каждую сторону. Он реализован в репозитории, а не взят как зависимость, потому что спецификация небольшая и полностью определённая, а тесты проверяются по опубликованным в RFC векторам — это более надёжная гарантия для критичного к безопасности пути, чем неаудированный модуль. Подключение состоит из двух шагов. POST /v1/panel/auth/totp сразу сохраняет секрет, но оставляет его неподтверждённым, а неподтверждённый секрет не удовлетворяет никакой проверке при входе, поэтому брошенное на полпути подключение оставляет аккаунт ровно таким, каким он был. POST /v1/panel/auth/totp/confirm активирует его и возвращает десять одноразовых кодов восстановления — единственный раз, когда они вообще возвращаются. Хранятся только их дайджесты SHA-256, и здесь это правильно, потому что у кодов равномерная энтропия и защищаться не от чего словарного. Коды восстановления — это две группы по пять символов из 32-символьного алфавита без неоднозначных I L O U. При сопоставлении нормализуются регистр, пробелы и дефис, поэтому перенабор с бумаги работает. Одноразовость — свойство SQL: повторно использованный код не обновляет ни одной строки. Подтверждение нового подключения удаляет и выпускает заново весь набор. Отключение двухфакторной аутентификации требует повторного ввода пароля, потому что снятие второго фактора — ровно то, что попытается сделать атакующий с угнанной сессией. Оно также удаляет все коды восстановления и ставит в очередь уведомление безопасности. Проверка сравнивает со всеми кандидатными временными шагами за постоянное время и не завершается досрочно, поэтому по времени нельзя понять, какой шаг совпал. Эндпоинт проверки ограничен 15 попытками в минуту на IP клиента, и это самое слабое из ограничений, потому что вызывающий может менять адреса:
  • Код принимается один раз. Аккаунт хранит временной шаг последнего принятого кода (totp_last_used_step) и отклоняет этот шаг и более ранние, поэтому код, подсмотренный через плечо или взятый из лога прокси, нельзя повторить в течение его 90-секундного окна. Код, подтвердивший подключение, расходуется так же.
  • Ожидающая сессия получает пять неверных кодов. Пятый отзывает её (revoked_reason = 'challenge_failed'), эндпоинт отвечает 401 unauthenticated, и для новой попытки снова нужен пароль.
  • Аккаунт считает неверные коды во всех сессиях (totp_failed_count) и применяет к ним ту же кривую блокировки, что и к паролю: пять неудач блокируют аккаунт с удвоением до одного часа. Верный пароль этот счётчик не сбрасывает — только верный код или код восстановления, — поэтому повторный вход не даёт новой порции попыток. Пока аккаунт заблокирован, и шаг пароля, и проверка отвечают 429 account_locked, ничего не проверяя.
При успешной проверке токен сессии ротируется тем же оператором SQL, который снимает состояние ожидания, поэтому cookie, удерживаемая во время проверки, не может быть воспроизведена как полностью аутентифицированная. APP_ADMIN_TOTP_ISSUER (по умолчанию Omniflow) — это метка, которая отображается в приложениях-аутентификаторах. Секреты запечатаны ключом APP_DATA_ENCRYPTION_KEY.
В этой сборке двухфакторную аутентификацию нельзя сделать обязательной. Она привязана к аккаунту и добровольна. Экран настроек панели предлагает секцию security с ключами requireTotp, sessionIdleMinutes, sessionAbsoluteHours и allowedOrigins; эти ключи сохраняются, но ничто в API их не читает, поэтому их установка ничего не меняет в поведении. Подключение должно быть операционным требованием, которое проверяется по списку операторов на /admin/operators, где показано состояние второго фактора у каждого аккаунта.

Passkey

Passkey выполняет вход сам по себе — без пароля и без второго фактора после него. Это не послабление: аутентификатор доказывает владение приватным ключом и проверяет, кто им пользуется, через PIN или биометрию, поэтому одна ассерция уже несёт два фактора. Требовать сверху ещё и код TOTP означало бы требовать третий. Регистрация требует обоих свойств и отклоняет учётные данные, у которых нет хотя бы одного. residentKey: required делает ключ обнаруживаемым — именно это позволяет экрану входа предложить его до того, как кто-либо ввёл адрес почты; необнаруживаемый ключ работал бы как второй фактор и при этом молча не появлялся бы при входе. userVerification: required проверяется дважды — запрашивается в запросе и проверяется в ответе до записи строки, — поэтому аутентификатор без PIN отклоняется при регистрации, а не при каждом входе. Хранится только публичный ключ. Приватная половина никогда не покидает аутентификатор, поэтому из таблицы admin_passkeys дамп базы не извлечёт ничего полезного. Ни публичный ключ, ни идентификатор учётных данных API не возвращает: они не секретны, но экрану со списком не нужны.

Что связывает passkey и что его ломает

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

Обнаружение клонов

Каждая ассерция несёт счётчик подписей. Аутентификатор, который его ведёт, увеличивает счётчик при каждом использовании, поэтому счётчик, который не продвинулся, означает, что за одни учётные данные отвечают два устройства, — это признак скопированного ключа. Такая ассерция отклоняется, а аккаунт получает уведомление: у владельца оригинала нет другого способа узнать о копии. Многие аутентификаторы — в том числе большинство платформенных — счётчик не ведут вовсе и всегда сообщают ноль. Это допустимо, и трактовать такое как клон означало бы заблокировать самое распространённое оборудование, поэтому «ноль при нуле» принимается, а отклоняется только счётчик, пошедший назад. Сохранённое значение обновляется через GREATEST, поэтому повторно отправленная ассерция не может откатить его так, чтобы следующий повтор выглядел свежим.

Челлендж и две половины API

Обмен WebAuthn состоит из двух запросов, и челлендж, выданный первым, должен дойти до второго неизменным. Он передаётся в запечатанной cookie с флагами HttpOnly и SameSite=Strict и временем жизни пять минут — ровно так же, как состояние потока OIDC: челлендж, который браузер может выбрать сам, челленджем не является. Он очищается до проверки ассерции, поэтому неудачная попытка не оставляет после себя ничего для повтора. Вход публичен по необходимости: ассерция и есть доказательство того, кто перед нами, поэтому POST /v1/panel/auth/passkey/login/begin и /finish работают до появления сессии. Управление ключами — нет: список, регистрация, переименование и отзыв находятся за проверкой сессии в /v1/panel/auth/passkeys, и каждая операция ограничена владельцем на уровне SQL, поэтому другой оператор с действующей сессией и действительным идентификатором passkey получает 404. Сессия, начатая по passkey, записывает passkey в список методов аутентификации — по нему читатель аудита отличает один способ входа от другого.

Пароль остаётся

Passkey добавляются рядом с паролем и вторым фактором, а не вместо них. Это сделано намеренно с двух сторон: пароль остаётся путём назад, когда потеряны все ключи, и, следовательно, отзыв passkey никогда не является событием восстановления доступа — последний ключ можно удалить, никого не заблокировав. Регистрация ключа, переименование и отзыв ставят в очередь уведомление безопасности, потому что регистрация нового способа входа — ровно то, что сделал бы завладевший чужой сессией. Операторы управляют своими ключами на /admin/security.

Сессии

Обе панели используют один и тот же примитив: 256-битный случайный токен, выдаваемый браузеру, и хранение только его дайджеста SHA-256. Сохранённая сессия должна быть бесполезна для того, кто прочитает хранилище, поэтому дамп базы или утёкшая резервная копия никогда не дают cookie, которая что-либо аутентифицирует. Одновременно работают два дедлайна, потому что каждого по отдельности недостаточно. Окно неактивности не даёт оставленному без присмотра браузеру быть полезным; абсолютное окно ограничивает украденную cookie даже тогда, когда фоновая вкладка поддерживает сессию живой. Запрос, сделанный прямо перед абсолютным горизонтом, не может сдвинуть дедлайн неактивности дальше него — сдвиг ограничивается абсолютным дедлайном на уровне SQL. Клиентская политика намеренно щедрее, и код это обосновывает: панель оператора двигает деньги и читает чужие данные, а повторный вход — это несколько секунд рабочего дня. Клиент, который смотрит, сколько дней осталось на его подписке, не делает ни того, ни другого, а панель, выкидывающая его за время обеда, просто возвращает его обратно в бот. Работу по безопасности там делает абсолютный горизонт, а не окно неактивности, подкреплённый ротацией и повторной аутентификацией на тех немногих действиях, которые этого действительно заслуживают. Ни одна из политик не настраивается через окружение. Значения по умолчанию действуют всегда.

Ротация и отзыв

Ротация невидима для обработчиков: middleware сессии централизованно переустанавливает cookie, когда токен ротируется. Отзыв проверяется раньше истечения срока, чтобы журнал аудита фиксировал более конкретную причину, когда применимы обе, а приостановленный оператор или приостановленный клиент получает отказ на этапе разрешения сессии, а не в каждом обработчике, — так ни одна поверхность не может забыть эту проверку. При отказе cookie очищается, чтобы браузер перестал предъявлять мёртвую сессию в каждом последующем запросе. revoked_reason — закрытый словарь. Сессии операторов используют logout, logout_all, password_change, admin_revoked, expired; сессии клиентов добавляют session_revoked, identity_unlinked, provider_disabled, account_suspended, account_deleted. Обе панели показывают свои живые сессии на /admin/security и /account/security с user agent, IP и временем последней активности, отмечают текущую и позволяют завершить любую другую. Два элемента «выйти везде» различаются намеренно: операторский сохраняет вызывающую сессию, чтобы оператора не выкинуло из браузера, в котором он занимается защитой аккаунта; клиентский не сохраняет, потому что существует для случая подозрения на компрометацию, и щадить сессию, которая нажала кнопку, значило бы оставить атакующего внутри, если кнопку нажал именно он. Завершить чужую сессию оператора угадыванием её идентификатора нельзя: отзыв сначала подтверждает владение, перечисляя собственные сессии вызывающего.
Строки истёкших сессий никогда не удаляются. В обоих сервисах есть процедура очистки и задокументированные сроки хранения — 30 дней для сессий и магических ссылок, 180 дней для событий безопасности клиента, — но ни одну из них ничто не вызывает. Воркер хранения подчищает сессии бота, события вебхуков, вложения обращений, события outbox, события телеметрии и записи расхождений и не трогает admin_sessions, customer_sessions, customer_magic_links и customer_security_events. Эти таблицы растут без ограничения. Истёкшие сессии не аутентифицируют — проверка идёт по дедлайну, а не по наличию строки, — поэтому это проблема хранения и гигиены, а не доступа.
SameSite=Lax блокирует большинство межсайтовых отправок, но это контроль только на стороне браузера, поэтому сервер проверяет собственный токен на каждом небезопасном методе. От Strict отказались осознанно: обратный вызов OIDC приходит как межсайтовый переход и должен приводить уже в авторизованное состояние. Небезопасные методы на деле защищает токен CSRF, а не политика cookie. Клиентские маршруты, которые открывают сессию, — POST /v1/account/auth/telegram, /auth/telegram/miniapp и /auth/link, — ещё не имеют токена сессии для проверки, поэтому от login CSRF их защищает то, что сообщает браузер: тело должно быть application/json (форма не может его отправить, а межсайтовый fetch с этим типом требует preflight, который никогда не одобряется), Sec-Fetch-Site должен быть same-origin или none, а браузер, не присылающий Sec-Fetch-Site, должен прислать Origin, чей хост совпадает с хостом запроса. Иначе ответ — 403 cross_origin_request или 415 unsupported_media_type. Ссылка для входа из бота на своём GET сессию не открывает; см. ссылку для входа.
  • GET, HEAD и OPTIONS освобождены. Любой другой метод обязан отправить X-CSRF-Token, совпадающий с собственным токеном сессии, иначе запрос — 403 csrf_failed.
  • Токен — это base64url(HMAC-SHA256(csrf_secret, <label>)), производный от секрета, который хранится вместе с сессией. Токен, выпущенный для одной сессии, нельзя воспроизвести в другой.
  • Метки у панелей разные — omniflow.admin.csrf и omniflow.customer.csrf, — поэтому ни одна панель не может выпустить учётные данные для другой.
  • Обе панели публикуют текущий токен в заголовке ответа X-CSRF-Token на каждом аутентифицированном запросе, и общий браузерный транспорт его подхватывает. Поэтому долгоживущая вкладка никогда не проваливает следующую мутацию после ротации сессии.
Флаги cookie у обеих сессионных cookie: Path=/, HttpOnly, SameSite=Lax, Secure согласно конфигурации и префикс __Host- всегда, когда установлен Secure. Важнее всего префикс: браузер отказывается принимать такую cookie, если она несёт атрибут Domain, поэтому соседний поддомен не может установить или перезаписать сессию оператора. Он применяется вместе с Secure, а не независимо от него, потому что то же правило, которое отвергает атрибут Domain, отвергает cookie целиком и при отсутствии Secure. Назвать cookie для обычного HTTP __Host- — это не ослабить её, а лишить браузер возможности её принять: при APP_ADMIN_COOKIE_SECURE=false и APP_CUSTOMER_COOKIE_SECURE=false вход стал бы невозможен, а не просто менее безопасен. Поэтому cookie без Secure называется omniflow_admin или omniflow_account — без префикса и честно о том, что она гарантирует. Имена различаются, чтобы обе панели можно было держать открытыми в одном браузере и ни одна не вытесняла другую.
Значение false предназначено только для локального стенда на обычном HTTP, где браузер иначе отказался бы принять cookie и вход никогда не завершился бы. API пишет предупреждение при запуске, когда это происходит. Без Secure сессионную cookie можно перехватить из любого незашифрованного запроса к тому же хосту.
Middleware CORS нет нигде. Браузерный транспорт работает в пределах одного источника, и обе панели должны обслуживаться с того же источника, который маршрутизирует /v1/* по пути на Go API — см. Обратный прокси.

Повышение уровня аутентификации

Четыре клиентских действия требуют входа не позднее чем 15 минут назад, иначе отвечают 401 reauthentication_required:
  • отвязка способа входа
  • ротация ссылки доступа к подписке
  • удаление всех устройств сразу
  • запрос на удаление аккаунта
Это те действия, которыми украденная, но всё ещё живая cookie могла бы иначе закрыть настоящему владельцу доступ к его собственной подписке. Каждое из них разрушительно, ни одно не отменяется из панели, и каждое клиент выполняет редко и осознанно. Отмена запроса на удаление повторной аутентификации не требует, потому что отмена только предотвращает необратимое. Точкой отсчёта свежести служит время создания сессии, а не её последней активности: по активности сессия, открытая весь день, считалась бы свежей. Требование вычисляется на каждом запросе, а не сохраняется, поэтому ответ никогда не устаревает. Отказ — 401, а не 403, потому что средство исправления клиенту доступно.

Запечатывание секретов и принцип «только на запись»

Каждые учётные данные, которые Omniflow хранит от имени оператора, запечатаны AES-GCM под APP_DATA_ENCRYPTION_KEY — ключом AES длиной 32 байта в кодировке base64. Сгенерируйте его командой openssl rand -base64 32. Сервис, получивший ключ любой другой длины, отказывается стартовать. Каждое поле запечатано под собственной меткой связанных данных, поэтому шифротекст, перенесённый между колонками, не открывается, а не расшифровывается там, где ему не место: Контактные данные клиентов запечатаны тем же ключом. Шифрованные резервные копии базы используют отдельный ключ, APP_BACKUP_ENCRYPTION_KEY, который должен храниться вне самой резервной копии. Принцип проектирования, стоящий над шифрованием, — учётные данные доступны только на запись. Ничто в API их не возвращает:
  • Настройки установки возвращают документ и булев флаг secretConfigured. Колонка с секретом не выбирается запросом, поэтому возврат документа безопасен по построению, а не благодаря фильтру, который рефакторинг может потерять.
  • Провайдеры OIDC возвращают hasClientSecret.
  • Настройки провайдеров платежей и товаров возвращают флаги credentialsSet.
  • Источники блок-листов никогда не возвращают сохранённый заголовок авторизации.
  • Сохранённые способы оплаты возвращают маскированную метку провайдера, но никогда — токен.
  • В каждом из этих случаев сохранение с пустыми или null-учётными данными означает оставить то, что хранится, поэтому форму можно пересохранить, не повторяя секрет.
Ротация APP_DATA_ENCRYPTION_KEY разом делает нечитаемым каждое запечатанное значение: секреты TOTP, клиентские секреты OIDC, учётные данные провайдеров и контактные данные клиентов. Пути повторного шифрования нет. Считайте ключ постоянным на всё время жизни установки и храните его резервную копию отдельно от базы данных — резервная копия, содержащая и то, и другое, представляет собой единый артефакт, который расшифровывает сам себя.

Доверенные прокси и определение IP клиента

Определённый адрес клиента питает ограничение частоты запросов, записи сессий, строки аудита и события безопасности клиентов. Заголовок X-Forwarded-For подделывается тривиально, поэтому он учитывается только тогда, когда непосредственный узел-собеседник — настроенный прокси. Задайте APP_TRUSTED_PROXIES как список IP-адресов или блоков CIDR через запятую. Голый адрес принимается и трактуется как сеть из одного узла. Неразбираемая запись — ошибка запуска. Когда переменная пуста, заголовок игнорируется полностью и используется адрес сокета, что является правильным значением по умолчанию для напрямую доступного развёртывания. Когда собеседник доверенный, список обходится справа налево, и определение останавливается на первом адресе, который сам не является доверенным прокси, — это ближайший узел, которому ещё есть основания верить. Ошибка здесь опасна в обе стороны: не доверять ничему за прокси — и все запросы делят один адрес, поэтому неудачные входы одного клиента исчерпывают бюджет на IP для всех; доверять слишком многому — и атакующий выбирает себе адрес сам. Клиент, чей адрес вообще не удаётся определить, делит ключ ограничения частоты unknown. Клиентская панель подключена к тому же списку; отдельной настройки для клиентов нет.

Ограничение частоты запросов

Лимиты — это счётчики с фиксированным окном на базе Valkey с ключами omniflow:rate:<scope>:<subject>. Добавление контакта ограничено, потому что ответ различает «принято» и «недоступно», а этого достаточно, чтобы массово проверять адреса, если повторять запрос свободно. Экспорт персональных данных ограничен, потому что он дорог в подготовке и максимально чувствителен при хранении. Лимиты работают на отказ (fail closed): если Valkey недоступен, запрос отклоняется и пишется предупреждение, потому что сбой не должен незаметно снимать защиту. Практическое следствие — недоступность Valkey полностью блокирует вход операторов. Заголовка Retry-After нет ни на одном 429. Сам вход клиента не ограничивается по частоте. Виджет Telegram, полезная нагрузка Mini App, погашение магической ссылки и оба маршрута OIDC не проходят через лимитер. Пути Telegram вместо этого ограничены проверками подписи и одноминутным окном свежести, а магические ссылки ограничены пятью на аккаунт в час на этапе выпуска.

Заголовки безопасности

Каждый ответ на /v1/panel и /v1/account несёт фиксированный ограничительный набор. frame-ancestors 'none' и X-Frame-Options: DENY существуют, чтобы панель оператора нельзя было встроить во фрейм и подвергнуть кликджекингу; они применяются на уровне API как эшелонированная защита. Клиентский набор — тот же самый без X-Frame-Options, и его no-store и no-referrer имеют там более весомое основание: ответ клиенту может содержать ссылку на подписку, а это учётные данные. no-store держит её вне общих кешей и вне back-forward-кеша браузера, а no-referrer не даёт передать URL с такой ссылкой туда, куда клиент перейдёт следующим шагом. Сами страницы отдаёт веб-приложение, и оно ставит собственный Content-Security-Policy на каждый HTML-ответ. Скрипты выполняются только с nonce, который выпускается на каждый запрос (script-src 'self' 'nonce-…' 'strict-dynamic'), плагины запрещены (object-src 'none'), а сама политика зависит от поверхности: Рекламные счётчики никогда не загружаются на страницах оператора, что бы ни было настроено. Каждый ответ на каждом префиксе также несёт X-Request-ID. Обработчик восстановления после паники и 60-секундный тайм-аут обработчика действуют на уровне всего процесса, а на самом сервере заданы ReadHeaderTimeout 5 с, ReadTimeout 15 с, WriteTimeout 30 с и IdleTimeout 90 с.

Что никогда не логируется и не возвращается

Процессы пишут JSON в stdout (воркер — в stderr) без middleware HTTP-логирования доступа, поэтому пути запросов, заголовки и тела по умолчанию в лог не попадают. Сверх этого:
  • Редактор-очиститель заменяет на [redacted] ссылки на подписку любой схемы (https, vless, vmess, trojan, ss), токены ботов Telegram, шаблоны bearer/basic/API-ключей/паролей, последовательности из 13–19 цифр и аппаратные идентификаторы и идентификаторы устройств. Он намеренно агрессивен: чрезмерно вычищенная строка лога — мелкое неудобство, а утёкшая ссылка на подписку или токен бота — инцидент. Он применяется к последней ошибке задачи перед тем, как она покинет процесс, и к каждой полезной нагрузке уведомления оператора.
  • Уведомления безопасности для операторов называют событие и аккаунт и больше ничего. Адрес, токен или IP клиента оказались бы в групповом чате; журнал аудита уже хранит эти подробности за правами доступа, которые ими управляют. Отрисовываются только поля из белого списка, поэтому всё прочее в полезной нагрузке игнорируется.
  • Вывод проверки готовности — это классификация, timeout или unavailable, но никогда не сообщение нижележащего драйвера, поэтому /readyz не может выдать строку подключения.
  • Метки метрик — ограниченный набор значений: шаблон маршрута, имя провайдера, исход, — но никогда конкретный путь с идентификаторами внутри.
  • История вебхуков никогда не возвращает сырое тело от провайдера. Это собственная полезная нагрузка провайдера, и она может содержать платёжные данные.
  • История выдачи доступа возвращает только статус, корреляцию и классификацию ошибки, потому что сохранённые сводки запроса и ответа могут нести ссылку на подписку.
  • Ссылки на подписку никогда не сохраняются. Колонки для них нет; ссылка запрашивается у Remnawave вживую на каждый запрос.
  • Списки устройств никогда не раскрывают аппаратный идентификатор. Обе панели адресуют устройство по дайджесту и разрешают его обратно на сервере, покупая один дополнительный вызов Remnawave в обмен на то, что идентификатор никогда не путешествует.
  • Метаданные журнала аудита — это контекст для оператора, но никогда не секрет, а экспорт в CSV предваряет апострофом любое поле, начинающееся с = + - @ TAB CR, чтобы электронная таблица не вычислила экспортированное значение как формулу.
Единственное место, где секрет пишется в лог намеренно, — это токен первичной настройки владельца на уровне warn, потому что до появления первого оператора другого канала доставки нет, а выдаваться он перестаёт в момент создания владельца. Не пересылайте этот лог в чат или в тикет. Токен сброса пароля оператора в лог не пишется никогда; его печатает только api admin reset-password.

Журнал аудита

Каждое действие аутентификации, авторизации, конфигурации, клиентское, финансовое, по поддержке, маркетинговое, системное и связанное с рисками добавляет строку в audit_events с актором, целью, действием, категорией, исходом, причиной, идентификатором запроса, отметкой времени и ограниченными метаданными. API предоставляет только чтение — ничто не может изменить строку, и именно это делает журнал доказательством, а не редактируемым состоянием. Изменение состояния и описывающая его строка аудита пишутся в одной транзакции, поэтому журнал никогда не может расходиться с тем, что произошло на самом деле. Отказ по правам доступа тоже записывается, а не только отклоняется: оператор, раз за разом тянущийся к тому, что ему не позволено, — ровно тот сигнал, который ищет разбор аудита. Для audit_events нет шага очистки. Финансовые, аудиторские, идентификационные, вебхуковые и outbox-записи по замыслу доступны только на добавление. Просматривайте журнал на /admin/audit с правом audit.read; выгружайте отфильтрованное представление в CSV с правом audit.export, с ограничением в 10 000 строк. Полные подробности — в Панели администратора.

Клиенты видят собственную историю безопасности

Клиенты читают отдельный журнал, доступный только на добавление, на /account/security: входы, выходы, отзывы сессий, запросы магических ссылок, изменения идентичностей, ротации ссылок на подписку и удаления устройств. Это другая таблица, не audit_events, потому что аудитория другая: решать построчно, какие поля операторской записи может увидеть клиент, значило бы поместить решение о раскрытии данных в путь чтения. Словарь событий там закрытый, и в таблице нет колонки для произвольного текста, суммы, ссылки или идентификатора другой стороны. IP включён намеренно: адрес, который клиент не узнаёт, — это и есть причина существования этого экрана. О выгрузке персональных данных и запросе на удаление см. Приватность клиента.

Требования к эксплуатации

Вот те части, которые оператор обязан обеспечить сам; приложение не может их навязать.
  • Держите PostgreSQL и Valkey вне публичной сети.
  • Обслуживайте обе панели по TLS с единого источника, который маршрутизирует /v1/* по пути на API, и оставьте APP_ADMIN_COOKIE_SECURE и APP_CUSTOMER_COOKIE_SECURE в значении true.
  • Задавайте APP_TRUSTED_PROXIES, когда API стоит за обратным прокси, и оставляйте её пустой, когда не стоит.
  • Ограничьте /metrics на обратном прокси; он не аутентифицирован всегда, когда включён APP_METRICS_ENABLED.
  • Храните APP_DATA_ENCRYPTION_KEY и APP_BACKUP_ENCRYPTION_KEY вне репозитория и вне резервных копий, а APP_OPERATOR_TOKEN ротируйте после любого подозрения на раскрытие.
  • Подключите TOTP каждому оператору и проверяйте состояние на /admin/operators — ничто этого не требует принудительно.
  • Выдавайте самую узкую роль, которая позволяет выполнять работу. Создавать оператора и менять назначенные роли может только владелец, и именно это не даёт скомпрометированной сессии администратора выпустить себе второй аккаунт.
  • Работайте на поддерживаемых патч-версиях Omniflow, Remnawave, PostgreSQL, Valkey и обратного прокси и просматривайте автоматические обновления зависимостей.

Сообщение об уязвимости

Не сообщайте об уязвимостях безопасности через публичные issue, обсуждения, логи или чаты. Используйте приватную отчётность об уязвимостях в GitHub на репозитории. Укажите затронутые версии, воздействие, шаги воспроизведения и любые предлагаемые меры смягчения. Удалите из отчёта реальные данные клиентов, токены, платёжные полезные нагрузки и ссылки доступа VPN перед отправкой. Мейнтейнеры подтверждают получение полных отчётов настолько быстро, насколько это практически возможно, проверяют воздействие, координируют исправление и публикуют бюллетень, когда пользователи могут что-то предпринять. Исправления безопасности выпускаются для последнего стабильного релиза.

Смежные страницы

Панель администратора

Роли, права доступа, сессии и журнал аудита в подробностях.

Вход через OIDC

Подключение провайдера идентичности для операторов или клиентов.

Интеграция с API

Поверхность с bearer-токеном и что означает владение этим токеном.

Резервное копирование и восстановление

Шифрованные резервные копии и где место второму ключу.