Перейти к содержанию

Безопасность и защита от абуза

Подробный, файл-и-строка уровень детализации по протоколам и rate-limiting — в dixu_proxy/README.md (разделы «Защита TCP-туннелей» и «Защита HTTP-интерстициала»). Этот файл — обзор модели угроз и того, что уже сделано, на уровне всего проекта.

Модель угроз: TCP-туннели

TCP-порты (SSH/RDP/VNC/PostgreSQL/MySQL/Redis/MongoDB) принимают произвольный slug в обращении — само обращение к произвольному (несуществующему) slug'у уже подозрительно, легитимный клиент ходит на свой один-два slug'а. Поэтому:

  • DixuProxyWeb.Tcp.RateLimiter — три независимых ETS-таблицы:
  • Rate-limit попыток хендшейка по IP (soft/hard-лимит → бан).
  • Детектор перебора slug'ов («diversity-бан») — много разных slug'ов с одного IP за окно → длинный бан, это сильный сигнал скана/энумерации.
  • Лимит одновременных соединений с одного IP (защита от истощения процессов/FD).
  • Унифицированные deny-сообщения — нет различия в ответе между "slug не существует" и "slug существует, но устройство офлайн" (устраняет account enumeration через текст ответа — было найдено внешним security-аудитом).
  • TCP-порты слушаются dixu_proxy напрямую через Docker port-forwarding, не через nginx stream-proxy — иначе прокси видел бы IP nginx у всех клиентов и rate-limiter не работал бы вообще (нашли и исправили при реализации; см. dixu_proxy/README.md раздел 3).

Модель угроз: HTTP (готовится под каталог туннелей)

В отличие от TCP, для HTTP-интерстициала diversity-бан в TCP-стиле не подходит: каталог туннелей подразумевает легитимный просмотр 10-20+ разных публичных туннелей за несколько минут — это нормальное поведение, а не признак атаки. Поэтому два отдельных механизма с разными целями:

  • DixuProxyWeb.Http.VisitGuard.allow?/1 — лимит ОБЪЁМА запросов с IP (анти-скрейпинг/анти-DDoS), не зависит от разнообразия slug'ов. Цель — не дать боту выкачать весь каталог за секунды.
  • DixuProxyWeb.Http.VisitGuard.record_slug/2 — отдельный, более узкий детектор перебора именно ПРИВАТНЫХ (не каталожных) slug'ов. Перед тем как засчитать slug в счётчик "подозрительного разнообразия", проверяет DixuProxy.CatalogRegistry.approved?/1 — каталожные slug'и исключаются из подсчёта, иначе механизм сломал бы саму функцию каталога.

Оба бана — в общую ETS-таблицу, общий allow?/1 видит бан от любого механизма.

Журнал инцидентов

DixuProxy.IncidentLog — общий журнал банов для TCP RateLimiter и HTTP VisitGuard: каждый бан дописывается строкой (timestamp\tcategory\tip\tduration\treason) в INCIDENT_LOG_DIR/YYYY-MM-DD.log. В проде путь — /app/incident_logs внутри контейнера, смонтирован volume'ом на ./INCIDENT/logs хоста (docker-compose.yml) — записи переживают пересборку контейнера и видны прямо в репозитории на сервере без захода внутрь контейнера. Сами *.log-файлы не коммитятся (.gitignore), только директория (.gitkeep).

Fail-open везде: ошибка записи в журнал не должна ронять защитный механизм.

Найденная и исправленная уязвимость: спуфинг X-Forwarded-For

При верификации журнала банов на проде было обнаружено: DixuProxyWeb.ClientIp брал первое значение из X-Forwarded-For, но nginx проставляет заголовок через $proxy_add_x_forwarded_for, который дописывает реальный IP клиента в конец цепочки, а не заменяет заголовок. Итоговая строка — "<что прислал клиент>, <реальный IP от nginx>". Взятие первого значения означало, что любой клиент мог полностью обойти все IP-based защиты (VisitGuard, метрики/гео в proxy_controller.ex) одним заголовком: curl -H 'X-Forwarded-For: 1.2.3.4'.

Подтверждено живым тестом на проде (forged-заголовок снял активный бан), исправлено — берём последнее значение (единственное звено, добавленное самим nginx, остальное под контролем клиента). Урок: при любом будущем изменении, которое доверяет заголовкам от reverse-proxy, проверять как именно nginx формирует заголовок (set vs add_x_forwarded_for), а не предполагать.

Видимость инцидентов: панель суперадмина, email-уведомления, кабинет

Каждый бан (TCP RateLimiter или HTTP VisitGuard) теперь, помимо файлового журнала выше, дополнительно пишется в таблицу Postgres tunnel_incidents (миграция V36) — структурированно, с target_slug, если бан был вызван перебором/энумерацией (diversity-бан) и среди перебранных slug'ов нашёлся реальный аккаунт (DixuProxy.IncidentLog.record/5 сверяет список попыток с users.slug через SELECT ... WHERE slug = ANY($1) — мусорные/ несуществующие slug'и не создают запись). Volume-баны (флуд по IP без привязки к конкретному slug'у) пишут строку с target_slug = NULL.

  • Суперадмин — вкладка «Инциденты» в /admin (AdminListsController /admin/incidents/list) — видит все инциденты платформы, с фильтром по IP/slug/категории.
  • Email-уведомление владельцу — если среди перебранных slug'ов нашёлся чей-то реальный туннель, и у владельца тариф VIP и вышеIncidentLog шлёт POST {MODULAUTH_URL}/internal/notify-attack (cooldown 30 минут на slug, ETS :attack_notify_cooldown, тот же паттерн, что у maybe_notify_offline/1) → InternalController.notifyAttackEmailService.sendAttackNotification.
  • Личный кабинет — раздел «Безопасность» (/auth/protected#security, тариф VIP+), API GET /api/incidents/my (IncidentsController) — только инциденты на собственном slug'е, серверная проверка роли независимо от клиентского скрытия.
  • Хранение — 90 дней (IncidentsCleanupScheduler, ежедневно 00:20), тот же паттерн, что AnalyticsCleanupScheduler.

Объяснение простыми словами: что за /internal/* и в чём была дыра

modulauth и dixu_proxy — два сервера, которые общаются друг с другом по HTTP внутри Docker-сети (например: админ нажал «заморозить туннель» в кабинете → modulauth говорит dixu_proxy «отключи устройство X»; или dixu_proxy обнаружил атаку → говорит modulauth «отправь письмо владельцу Y»). Такие вызовы помечены префиксом /internal/ — по дизайну их должен звать только другой сервер, а не человек из браузера, поэтому никакого логина/пароля на этом пути не предполагалось вообще.

Единственное, что не давало вызвать эти пути напрямую из интернета — одна строчка в конфиге nginx (location ^~ /internal/ { return 404; }). На апекс-домене dix.su (главный блок, который ведёт прямо на modulauth) эта строчка отсутствовала. То есть буквально любой человек в интернете мог выполнить:

curl -X POST https://dix.su/internal/notify-attack \
  -d '{"slug":"чей-то-slug","ip":"1.2.3.4","reason":"фейк"}'

— и modulauth, ничего не проверяя, честно отправлял email-уведомление владельцу этого slug'а. А со стороны dixu_proxy было ещё опаснее: у /internal/tunnels/:slug/freeze (заморозить чужой туннель) и /internal/tunnels/online в самом коде роутера вообще не было НИКАКОЙ проверки — защита держалась только на той же строчке nginx. Если бы её когда-нибудь забыли при правке шаблона — управление любым туннелем платформы стало бы публичным (классический DoS: замораживать чужие туннели, зная только slug, без какой-либо учётной записи).

Сделали два независимых уровня вместо одного: (1) добавили пропущенную строчку nginx на апекс-домен; (2) добавили секретный заголовок (INTERNAL_API_SECRET, сверяется constant-time-сравнением, см. ниже) прямо в код dixu_proxy — теперь даже если nginx когда-нибудь ошибётся, без секрета запрос получит 401 и не дойдёт до логики freeze/disconnect.

Найденная и исправленная уязвимость: /internal/** modulauth был публично доступен

При тестировании /internal/notify-attack обнаружено: апекс-домен dix.su в nginx (location /modulauth) не имел guard'а location ^~ /internal/ { return 404; }, который уже стоял на двух других vhost'ах (*.dix.su, proxy.dix.su) — то есть любой человек в интернете мог POST'ить на /internal/notify-attack (а также на уже существовавшие /internal/notify-offline//notify-service-down) с произвольным slug, зная который — спамить email-уведомлениями любому пользователю без какой-либо авторизации (Spring SecurityConfig держит /internal/** как permitAll по дизайну — это нормально для серверных вызовов dixu_proxy→modulauth, но требует, чтобы nginx гарантированно не пускал туда снаружи). Исправлено — добавлен такой же guard на апекс-блок; проверено живым curl с публичного интернета (404 после фикса на всех трёх /internal/notify-*).

Defense-in-depth: INTERNAL_API_SECRET на /internal/* обеих сторон

Независимый аудит обнаружил тот же класс риска и со стороны dixu_proxy: /internal/tunnels/online, /internal/tunnel/:slug (статус/disconnect), /internal/tunnels/:slug/freeze|unfreeze (управление ЧУЖИМИ туннелями, включая DoS через freeze) не имели вообще никакой аутентификации в Phoenix-роутере — защита держалась исключительно на том же nginx-guard'е. Это рабочая, но единая точка отказа: один забытый/удалённый при правке шаблона guard — и управление любым туннелем платформы становится публичным.

Добавлен второй, независимый от nginx уровень: заголовок X-Internal-Secret, сверяется constant-time (Plug.Crypto.secure_compare/2) с INTERNAL_API_SECRET в новом DixuProxyWeb.Plugs.InternalAuth (pipeline :internal_api в router.ex). Modulauth добавляет этот заголовок на всех 6 точках вызова /internal/* у dixu_proxy (UnifiedAuthService, AdminListsController, AdminCatalogController, TunnelController, PublicProfileController, CatalogController). Fail-open, если INTERNAL_API_SECRET не задан в .env (чтобы не сломать окружения без него) — dixu_proxy логирует warning при старте, если секрет не настроен. Задать секрет в проде: openssl rand -hex 32.envINTERNAL_API_SECRET=... (один и тот же секрет для modulauth и dixu_proxy, см. docker-compose.yml) → пересобрать оба сервиса.

Статус на проде: секрет настроен (64 hex-символа, сгенерирован openssl rand -hex 32). Итоговая модель — двухуровневая защита: nginx блокирует /internal/* на публичных vhost'ах (первый уровень), Phoenix-роут дополнительно требует совпадение секрета через constant-time сравнение (второй, независимый от nginx-конфига уровень). Если кто-то когда-нибудь случайно удалит location ^~ /internal/ { return 404; } при правке шаблона — это больше не открывает управление чужими туннелями: без секрета InternalAuth всё равно отдаст 401.

Остаточный риск: тихий fail-open при пропадании секрета

Сам plug fail-open, если INTERNAL_API_SECRET не задан в окружении (nil/"" → пропускает без проверки, см. выше). Сейчас, когда секрет реально установлен, это не уязвимость. Но если переменная когда-нибудь пропадёт из .env/docker-compose.yml (опечатка, забыли при пересборке, новый деплой без секрета) — защита тихо отключится сама, без ошибки запуска.

Рассматривался вариант fail-closed (крашить dixu_proxy при старте в проде, если секрет не задан, через явную проверку в runtime.exs) — решение: не делать так, потому что отказ всего туннельного сервиса из-за отсутствующей переменной — слишком дорогая цена за один уровень defense-in-depth, когда первый уровень (nginx) продолжает работать сам по себе. Вместо этого выбран видимый, но не ломающий прод сигнал: постоянный баннер в /admin (admin-catalog.html, модель-атрибут internalSecretMissing из AdminCatalogController.catalogPage) — виден суперадмину при каждом заходе в панель, пока секрет не настроен, без кнопки "скрыть" (баннер не исчезает сам, исчезает только когда проблема реально устранена). dixu_proxy дополнительно логирует warning при старте — для тех, кто смотрит логи контейнера, а не только админку.

Приватный режим: access-gate для HTTP/HTTPS-туннелей

Приватный аккаунт (users.private_mode=true, см. docs/BILLING.md) должен быть доступен только владельцу, а не любому, кто знает URL. Для raw-TCP-протоколов (SSH/RDP/VNC/Postgres/MySQL/Redis/MongoDB) это уже верно сегодня — dixu_proxy для них слепой форвардер, контента не видит. Для HTTP/HTTPS, где сервер терминирует TLS (нужно для интерстициала/каталога), понадобился отдельный механизм:

  1. Cookie сессии modulauth теперь видна на поддоменахspring.webflux.session.cookie.domain=.${BASE_DOMAIN} (раньше — host-only на dix.su, не видна на {slug}.dix.su). SameSite=Lax это не блокирует (тот же registrable domain).
  2. GET /internal/session/whoami (InternalController.java) — dixu_proxy форвардит туда заголовок Cookie целиком + X-Internal-Secret, modulauth резолвит сессию штатным образом (как isAdmin(exchange) в AdminCatalogController), возвращает {authenticated, userId, email}. Чувствительнее обычных /internal/* (отдаёт PII визитёра) — секрет сверяется явно constant-time-сравнением (MessageDigest.isEqual) внутри самого эндпоинта, не только на уровне nginx-guard'а.
  3. dixu_proxy.InterstitialPlug.check_private_access/2 — для приватных slug'ов перед обычной интерстициальной логикой вызывает whoami (результат кэшируется в ETS :owner_session_cache, TTL 60с — иначе на каждый суб-ресурс страницы был бы отдельный сетевой запрос), сравнивает userId с владельцем slug'а. Совпало — пропускает прямо к устройству, минуя рекламу/интерстициал. Не совпало (включая случай, когда визитёр не залогинен вовсе) — страница «Приватный туннель» с кнопкой Войти → на https://{BASE_DOMAIN}/auth/login-page?return_to=<url>.
  4. return_to после логина — и form-login (SecurityConfig), и OAuth2 (OAuth2AuthSuccessHandler) после успешной аутентификации проверяют pendingReturnTo в сессии (положен туда на GET /auth/login-page) и редиректят туда вместо /auth/protected. Валидация через ReturnToValidator.isValid — только http(s)-ссылки на сам домен или его поддомены, иначе игнорируется (защита от open-redirect).

Приглашённые пользователи (PRO и выше) — владелец приватного аккаунта на тарифе PRO/PERS может пригласить других зарегистрированных пользователей dix.su по их slug'у (/auth/protected#private, PrivateInviteController, таблица private_tunnel_invites, миграция V38). check_private_access/2 в dixu_proxy при несовпадении userId с владельцем дополнительно проверяет наличие приглашения и свежую роль владельца (role_at_least?/2, минимум PRO) — при понижении роли владельца ниже PRO приглашённые теряют доступ немедленно на следующий запрос, без необходимости удалять сами записи приглашений. Лимиты (PRO: 10, PERS: 100) проверяются только на стороне modulauth при добавлении — dixu_proxy не дублирует подсчёт, только факт наличия строки в private_tunnel_invites.

Без пароля/инвайт-токена на сам туннель — приглашение строго привязано к существующему зарегистрированному аккаунту dix.su (по slug'у), не к анонимной ссылке.

Чего этот механизм не даёт: сервер по-прежнему терминирует TLS и технически может видеть содержимое HTTP-трафика приватного веб-туннеля — access-gate решает проблему доступа («кто угодно по ссылке»), а не видимости контента платформой. Полная невидимость контента для HTTP — это Фаза 2 (сквозное шифрование, SNI-passthrough), см. ниже.

Фаза 2: сквозное шифрование (E2E) для приватных туннелей

Ключевое архитектурное ограничение: если сервер не расшифровывает TLS, он не может прочитать cookie-сессию — значит access-gate Фазы 1 (выше) физически не работает для хоста, на котором нет терминации. Единственное, что видно сервером в открытом виде в TLS ClientHello — SNI (имя хоста). Поэтому Фаза 2 не заменяет Фазу 1, а дополняет её отдельным, независимым входом:

  • {slug}.{BASE_DOMAIN} — как раньше, терминация на сервере, доступ по сессии (Фаза 1).
  • e2e-<token>.{BASE_DOMAIN} — новый вход, без терминации TLS на сервере вообще. Доступ контролируется обладанием персональным токеном-в-имени- хоста, а не сессией; лимит приглашённых (PRO: 10, PERS: 100) не дублируется отдельной системой — токен выдаётся только тому, кто уже есть в private_tunnel_invites (тот же лимит, что у Фазы 1).

Серверная часть (реализована)

  • private_e2e_tokens (миграция V39) — owner_user_id, holder_user_id (владелец сам себе, либо приглашённый), token (32-байтный hex, SecureRandom), revoked_at. Один активный токен на пару (владелец, держатель).
  • PrivateE2eController (/api/private/e2e) — POST /mint (владелец приватного аккаунта — токен на себя; приглашённый, есть запись в private_tunnel_invites — токен на пару с владельцем; иначе 403), GET /my, DELETE /{id}.
  • Резерв префикса e2e- в ProfileApiController.validateSlugFormat — обычный пользователь не может занять slug, который коллизирует с этим маршрутом. Известное ограничение: автогенерируемый при регистрации slug (User.generateSlug(), на основе email) через эту проверку не проходит — крайне маловероятная коллизия, осознанно не закрыта.
  • CloudflareDnsService + AcmeDnsChallengeController (/api/private/e2e/acme/dns-challenge, POST/DELETE, permitAll в SecurityConfig — авторизация своя, не сессионная, см. ниже) — DNS-01 helper: ставит/убирает TXT-запись _acme-challenge.e2e-<token>.{BASE_DOMAIN} через Cloudflare API (тот же CLOUDFLARE_API_TOKEN, что у wildcard-сертификата платформы, см. Makefile make certbot), чтобы устройство могло получить свой собственный сертификат Let's Encrypt. Авторизация запроса — той же парой (slug, token), которой устройство уже аутентифицируется на WSS-канале (device_socket.ex), плюс проверка, что указанный e2e-токен принадлежит туннелю именно этого устройства. Платформа никогда не видит и не запрашивает приватный ключ устройства — ACME-диалог (Let's Encrypt ↔ устройство) идёт напрямую, helper только проксирует один DNS TXT challenge. Получение CLOUDFLARE_API_TOKEN и нужные права — см. .env.example (Custom token, Zone:DNS:Edit + Zone:Zone:Read, scope на зону {BASE_DOMAIN}; Zone:Read нужен для динамического резолва zone id внутри CloudflareDnsService, отдельная переменная под zone id не нужна). Найденная и исправленная уязвимость: путь не был в permitAll — Spring Security перехватывал запрос редиректом на /auth/login-page раньше, чем доходило до собственной проверки (slug, token) в контроллере (найдено живым тестом device_client, коммит e27aee9).
  • DixuProxyWeb.Tcp.E2eRawListener (внутренний порт 9443, не публикуется на хост) — raw TCP relay без терминации TLS. Парсит PROXY protocol v1 (восстанавливает реальный IP клиента после хопа nginx stream{} — без этого RateLimiter/IncidentLog видели бы только IP nginx, та же проблема, из-за которой остальные TCP-протоколы исторически идут мимо stream{} целиком, см. выше), затем парсит SNI из байт TLS ClientHello БЕЗ завершения handshake (переиспользует TcpListener.extract_sni/1/read_full_tls_record/2 — публичные функции, вынесенные специально для этого реюза). Хост e2e-<token> резолвится в owner_user_idslugDeviceRegistry lookup, дальше — слепой двусторонний релей байт в WSS-канал устройства. В отличие от TLS-терминирующих протоколов (Postgres/MySQL/Redis/MongoDB в tcp_listener.ex), здесь нет :ssl.handshake() вообще — принципиально, иначе сервер обратно получил бы видимость контента.
  • nginx stream{} (stream.conf.template) — ssl_preread on + map $ssl_preread_server_name: хосты ~^e2e-dixu_proxy:9443 (без терминации), всё остальное → 127.0.0.1:8443 (обычная терминация, как и раньше, теперь за одним дополнительным TCP-хопом). Выкатывалось в два подэтапа намеренно — изменение nginx затрагивает 100% трафика сайта:
  • Подэтап 2a — добавляет только проходной слой (всё на 127.0.0.1:8443, без E2E-ветки) + set_real_ip_from/real_ip_header proxy_protocol на всех vhost'ах в dixu.conf.template (иначе $remote_addr после хопа стал бы адресом самого nginx — сломало бы X-Forwarded-For, геоаналитику, rate-limiting/баны по IP для всего сайта, не только приватных туннелей). Не меняет наблюдаемое поведение ни для одного существующего хоста — проверяется и деплоится в прод отдельно, ДО 2b.
  • Подэтап 2b — добавляет map-ветку ^e2e-dixu_proxy:9443. Деплоится после того, как 2a подтверждён в проде (реальный IP клиента не теряется).

device_client — реализовано и проверено на проде

ACME-клиент (DNS-01, напрямую устройство↔Let's Encrypt, сервер проксирует только один DNS TXT challenge через AcmeDnsChallengeController) и локальный TLS-терминатор (DeviceClient.E2eListener, слушает 127.0.0.1:8443, сертификат Let's Encrypt, приватный ключ никогда не уходит с устройства) реализованы в отдельном репозитории websocket-client/device_client_app (Elixir/OTP, Burrito-бинарник для 5 платформ: linux x86_64/arm64, macOS arm64/x86_64, Windows x86_64).

End-to-end путь клиент→nginx→dixu_proxy→устройство проверен на проде (Chrome, Firefox). Подэтапы 2a/2b задеплоены, X-Forwarded-For/геоаналитика/rate-limiting работают корректно через хоп stream{}→8443.

Статус всей фичи: полностью реализовано и задеплоено. Документация клиентской части — README.md в репозитории websocket-client/device_client_app.

Известный результат внешнего security-аудита

Аудит eskimos.dix.su (см. историю — пункт уже закрыт): TCP account enumeration через differential error messages, отсутствие rate-limiting. Оба пункта закрыты реализацией выше (унифицированные deny-сообщения + RateLimiter).

Пользовательская документация

/tunnel на сайте — раздел "Правила добросовестного использования TCP-туннелей" (modulauth/.../templates/tunnel-landing.html), без точных числовых порогов (чтобы не давать готовый рецепт обхода).