Безопасность и защита от абуза¶
Подробный, файл-и-строка уровень детализации по протоколам и 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.notifyAttack→EmailService.sendAttackNotification. - Личный кабинет — раздел «Безопасность» (
/auth/protected#security, тариф VIP+), APIGET /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 → .env → INTERNAL_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 (нужно для интерстициала/каталога),
понадобился отдельный механизм:
- Cookie сессии modulauth теперь видна на поддоменах —
spring.webflux.session.cookie.domain=.${BASE_DOMAIN}(раньше — host-only наdix.su, не видна на{slug}.dix.su).SameSite=Laxэто не блокирует (тот же registrable domain). 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'а.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>.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-сертификата платформы, см.Makefilemake 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 клиента после хопа nginxstream{}— без этогоRateLimiter/IncidentLogвидели бы только IP nginx, та же проблема, из-за которой остальные TCP-протоколы исторически идут мимоstream{}целиком, см. выше), затем парсит SNI из байт TLS ClientHello БЕЗ завершения handshake (переиспользуетTcpListener.extract_sni/1/read_full_tls_record/2— публичные функции, вынесенные специально для этого реюза). Хостe2e-<token>резолвится вowner_user_id→slug→DeviceRegistrylookup, дальше — слепой двусторонний релей байт в 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), без точных
числовых порогов (чтобы не давать готовый рецепт обхода).