PunchNavy arrests fake officer, suspected robber in AnambraThe Jerusalem PostNetanyahu announces planned libel lawsuit over Haaretz report claiming UAE warned of Oct. 7וואלהשרה נתניהו קיבלה מכתב התראה לפני תביעה ממפגינים שהציתו פחיםInquirer EntertainmentBaron Geisler, wife unfollow each other on Instagram amid her cryptic postsRTP DesportoLiga dos Campeões. FC Porto cai no Dragão frente ao Manchester CityDaily MaverickTHE EDUCATION DESK: SA Principals Association president issues 5-step appeal to ease burnout pressureBollywood HungamaAyushmann Khurrana literally hangs between India and Pakistan in quirky first poster of Udta Teer; film to release on October 9ESPNTransfer rumors, news: Man United blow as Hall agrees new Newcastle dealInquirerPalace assures efforts being done to curb rising unemploymentХабрЗачем я поставил «калитку» перед reverse proxy — и почему обычного логина мне оказалось малоUOLOposição israelense ataca Netanyahu após denúncia sobre alerta antes de 7 de OutubroVilaWebEl govern espanyol obre un expedient als policies que van agredir un jove per una estelada a Elx
The Daily Newsstand · Free, Always
Wednesday, September 9, 2026

Зачем я поставил «калитку» перед reverse proxy — и почему обычного логина мне оказалось мало

Translate

У каждого из них уже есть собственная авторизация. Казалось бы, что ещё нужно?

Но меня долго смущала одна простая вещь: почему форму логина вообще должен видеть весь интернет?

Да, за ней стоит пароль. Возможно, MFA. Возможно, очень хороший механизм аутентификации.

Но сам сервис всё равно торчит наружу. Его можно сканировать, определять по заголовкам и поведению, искать конкретную версию, долбить login endpoint, перебирать известные CVE.

Можно решить вопрос VPN.

Можно поднять Authentik или Authelia.

Можно использовать Cloudflare Access или другой Zero Trust.

Можно добавить Basic Auth перед обычной авторизацией и получить прекрасный UX из двух логинов подряд.

Но для нескольких моих сервисов всё это казалось слишком большим решением для очень маленькой задачи.

Мне хотелось буквально следующего:

Если к закрытой двери подошёл незнакомый человек — пусть сначала позвонит.
А я решу, открывать ему или нет.

Так появилась Kalitka.

Почему «Калитка»

Калитка — это маленькая дверь в заборе рядом с большими воротами.

Она не заменяет дверь дома.

На ней не обязательно стоит сложная система идентификации личности.

Она просто не позволяет любому прохожему сразу оказаться у вашего крыльца.

Это довольно точно описывает идею проекта.

Kalitka не является Identity Provider.

Она не заменяет авторизацию приложения.

Она не является WAF.

Это небольшой pre-filter перед приложением:

visitor
   │
   ▼
reverse proxy
   │
   │ forwardAuth
   ▼
 Kalitka ──────────► Telegram
   │                    │
   │                 Allow?
   │                    │
   ◄────────────────────┘
   │
   ▼
actual application
   │
   ▼
its own login

Пока я не разрешил запрос, пользователь вообще не доходит до настоящего приложения.

Как это выглядит для пользователя

Допустим, есть:

https://grafana.example.com

Traefik получает запрос и перед тем, как отдать его Grafana, обращается к Kalitka через forwardAuth.

Если пользователь уже разрешён — Kalitka отвечает 200, и Traefik продолжает запрос.

Если нет — пользователь получает redirect на:

https://gate.example.com/request?target=grafana.example.com

Там можно представиться.

Например:

sergey@example.com

или просто:

Sergey

После этого запрос переходит в состояние ожидания, а мне в Telegram приходит сообщение примерно с такой информацией:

Someone is at the gate

Target: grafana.example.com
Identity: sergey@example.com
IP: 203.0.113.42
Location: Cologne, Germany

И две кнопки:

Allow     Deny

Разрешил — браузер получает подписанную session cookie и идёт дальше.

И только теперь пользователь видит настоящий login Grafana.

То есть наличие разрешения Kalitka ещё не означает наличие доступа к Grafana.

Это две разные вещи.

И это для меня было принципиально.

Почему не просто VPN

VPN прекрасно решает подобные задачи.

У меня нет никаких претензий к WireGuard.

Но VPN хорошо работает, если круг пользователей известен заранее.

Мои сценарии немного другие.

Например, мне нужно на несколько часов дать доступ человеку с ноутбука, который я не контролирую.

Или я сам нахожусь в дороге.

Или необходимо быстро показать staging-систему.

Начинается:

— установи WireGuard;
— вот конфиг;
— импортируй;
— включи tunnel;
— нет, на телефоне другая кнопка;
— а теперь попробуй открыть снова.

Для постоянного административного доступа — замечательно.

Для ситуации «открой человеку вот эту дверь один раз» — мне хотелось чего-то проще.

Почему не IP allowlist

Первой мыслью была обычная белая таблица IP.

Она действительно проще всего.

До первой поездки.

Мобильные сети, CGNAT, гостиничный Wi-Fi, домашний динамический IP — и список начинает жить своей жизнью.

При этом IP-фильтрация всё равно осталась полезной частью Kalitka.

Просто я перестал считать IP полноценной идентичностью.

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

Если надоел — можно заблокировать.

Но неизвестный IP не означает автоматически злоумышленника.

Он просто звонит в калитку.

Почему не Authentik / Authelia

Потому что это немного другая задача.

Мне не хотелось строить ещё одну полноценную систему идентификации.

Если приложение умеет нормально логинить пользователя — пусть продолжает это делать.

Kalitka отвечает только на вопрос:

«Можно ли этому запросу вообще показать приложение?»

Это намеренно гораздо более слабая семантика.

Если человек написал в поле:

Elon Musk

Kalitka не делает вывод, что это действительно Илон Маск.

Она сообщает мне:

Кто-то представился как Elon Musk. Пустить?

Поэтому я сознательно называю это approval, а не authentication.

Впрочем, для пользователей, которых действительно можно идентифицировать заранее, я добавил опциональный Google Sign-In.

Но к нему мы ещё вернёмся — там возник интересный архитектурный нюанс.

ForwardAuth оказался простой частью

На первый взгляд весь сервис выглядит почти смешно.

Traefik делает примерно это:

GET /auth
X-Forwarded-Host: grafana.example.com
X-Forwarded-For: 203.0.113.42

Ответ:

200

— пропускаем.

Или:

302
Location: https://gate.example.com/request?target=grafana.example.com

— отправляем к Kalitka.

Первая версия действительно появилась довольно быстро.

А потом начались вопросы, которые обычно начинаются после слов:

«Ну, оно уже работает».

Какой у клиента IP?

Кажется очевидным:

X-Forwarded-For

Но есть маленькая проблема.

Этот заголовок может прислать сам клиент.

Например:

X-Forwarded-For: 10.0.0.15

Если 10.0.0.0/8 находится в bypass list и приложение слепо доверяет заголовку, поздравляю: мы только что самостоятельно добавили endpoint обхода собственной защиты.

Поэтому Kalitka доверяет forwarded headers только в том случае, если TCP-соединение пришло от явно доверенного proxy.

То есть конфиг содержит TrustedProxies.

Условно:

Internet
   │
   ▼
Traefik
172.18.0.3
   │
   ▼
Kalitka

Kalitka знает:

172.18.0.0/16 = trusted proxy network

Если запрос действительно пришёл оттуда — можно начинать разбирать X-Forwarded-For.

Если нет — заголовок игнорируется.

И тут появляется вторая ловушка

Многие берут первый IP из:

X-Forwarded-For:
client, proxy1, proxy2

То есть самый левый.

Но именно его особенно легко контролировать клиенту, если proxy добавляет адрес к существующему заголовку.

Надёжнее идти справа налево:

client, proxy1, proxy2
                ◄──────

Пока встречаются известные trusted proxies — пропускаем их.

Первый адрес, который не принадлежит доверенной proxy-инфраструктуре, и есть кандидат на client IP.

Это одна из тех мелочей, которые кажутся избыточными ровно до момента, когда от IP начинают зависеть:

  • allow list;

  • block list;

  • LAN bypass;

  • GeoIP;

  • audit;

  • уведомления.

Fail-open или fail-closed?

Следующий вопрос:

Что должно произойти, если Kalitka умерла?

Можно выбрать UX:

Kalitka unavailable → ну ладно, пропустим

Это fail-open.

Удобно.

Только тогда вся система является защитой ровно до первого:

docker stop kalitka

Поэтому я выбрал fail-closed.

Если reverse proxy не может получить разрешение — приложение недоступно.

Это создаёт другой риск: можно случайно запереть самого себя снаружи.

Поэтому есть BypassNetworks.

Например, локальная сеть:

192.168.1.0/24

из которой сервисы доступны независимо от approval.

Но и здесь снова появился интересный edge case.

Представим:

BypassNetworks = Docker network
TrustedProxies = empty

Что тогда видит Kalitka?

Все запросы приходят от Traefik.

А Traefik находится именно в bypass network.

Следовательно:

Internet user
    ↓
Traefik = trusted by accident
    ↓
everyone bypasses Kalitka

Причём конфигурация внешне выглядит совершенно разумно.

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

Мне всё больше нравится принцип:

неправильная security-конфигурация должна громко ломать deployment, а не тихо делать его небезопасным.

Host header — ещё один интересный момент

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

Наивная реализация:

target = Host
redirect("https://" + target)

И у нас появляется open redirect / host-header injection.

Поэтому Kalitka не считает произвольный host допустимой целью.

Сначала администратор явно включает защиту хоста:

grafana.example.com
n8n.example.com
admin.example.com

И только такой host может использоваться как target.

То есть даже если кто-то подделает:

Host: evil.example

это не превратит Kalitka в redirector на произвольный домен.

Эта же идея позволяет удобно подключать middleware сразу к большому количеству сервисов.

Сам факт присутствия forwardAuth не означает, что хост закрыт.

Хост нужно отдельно arm.

А теперь cookies

После разрешения нужно как-то запомнить решение.

Мне не хотелось хранить server-side sessions.

Поэтому сессия Kalitka — это подписанная cookie.

Примерно концептуально:

host=grafana.example.com
expires=...
signature=HMAC(...)

Cookie:

Secure
HttpOnly
SameSite=Lax

Подпись не позволяет пользователю самостоятельно изменить host или срок действия.

Ротация HMAC secret автоматически инвалидирует все существующие сессии.

Удобно.

Но тут возникает гораздо более интересная проблема.

Почему cookie нельзя просто сделать Host-only

Kalitka находится здесь:

gate.example.com

а защищаемое приложение здесь:

grafana.example.com

Cookie устанавливает gate.example.com.

Браузер не позволит этому хосту поставить Host-only cookie для:

grafana.example.com

И правильно сделает.

Поэтому текущая схема использует parent domain:

Domain=.example.com

Теперь cookie браузер отправляет sibling-хостам.

Но внутри manual approval подписан конкретный target:

grafana.example.com

Поэтому approval Grafana нельзя просто использовать для n8n.

То есть cookie физически доступна sibling hosts, но её логическое содержимое host-bound.

И здесь всё работало вполне неплохо.

До Google OAuth.

Google Sign-In и неожиданная разница в Session Scope

Для заранее известных пользователей я добавил Google Sign-In.

Например, можно разрешить:

*@example.com

или конкретные аккаунты.

Человек подтверждает свою личность через Google и получает доступ без ожидания Telegram approval.

И тут возник вопрос:

А для какого host выпускать Google session?

Можно привязать её только к текущему target.

Но тогда при переходе:

grafana.example.com
        ↓
n8n.example.com

человеку придётся снова идти через OAuth.

Можно дать global session.

Так сейчас и работает.

И это означает важное различие:

Manual approval

Approve Grafana
→ Grafana

не открывает автоматически:

n8n.example.com

Google approval

Sign in
→ вся группа guarded hosts внутри CookieDomain

То есть security scope разный.

Мне кажется неправильным скрывать такие особенности за красивым README, поэтому это сейчас явно описано как sharp edge.

Следующая логичная архитектура — per-host session через одноразовый handoff token.

Как можно сделать настоящий Host-only session

Схема получается интересная.

После approval на:

gate.example.com

Kalitka выпускает короткоживущий одноразовый token:

aud = grafana.example.com
jti = random
exp = now + 30 sec

И отправляет браузер:

https://grafana.example.com/?__kalitka=<token>

Запрос снова попадает в Traefik.

Traefik снова вызывает forwardAuth.

Kalitka видит token, проверяет:

  • подпись;

  • TTL;

  • target host;

  • jti;

  • что token ещё не использован.

После этого forwardAuth отвечает:

Set-Cookie: kalitka=...; Secure; HttpOnly; SameSite=Lax

уже в контексте ответа для:

grafana.example.com

Traefik пробрасывает Set-Cookie клиенту.

Теперь Domain не нужен.

Получается настоящая Host-only cookie.

После установки cookie браузер сразу редиректится на чистый URL без токена.

Например:

https://grafana.example.com/

Так token не остаётся в location bar дольше одного запроса.

Это уже более сложный механизм.

И именно поэтому я пока не стал впихивать его в первую версию.

Один из принципов проекта — не добавлять complexity только потому, что архитектурно можно сделать красивее.

Telegram как control plane

Здесь мне особенно нравится одна вещь.

Для Kalitka Telegram — не identity provider.

Это практически control plane.

Через бота можно:

/allowed
/blocked
/hosts
/mute
/unmute
/session

Можно заранее добавить:

/allow ip ...
/allow name ...

или:

/block ip ...
/block name ...
/block country ...

Но я специально не сделал:

/allow country DE

Потому что:

«всех из Германии пускаем внутрь»

— это довольно странная модель безопасности.

Country имеет смысл как грубый отрицательный фильтр.

Но не как доказательство того, что человеку можно доверять.

Зачем нужен /mute

Есть ещё один забавный operational case.

Представьте, что кто-то решил автоматически дёргать endpoint.

Kalitka честно делает то, ради чего была создана:

🔔 Telegram
🔔 Telegram
🔔 Telegram
🔔 Telegram

Технически всё работает идеально.

Пользоваться невозможно.

Для этого появился:

/mute 480

В этом режиме новые неизвестные callers не вызывают уведомление, а молча блокируются.

Это не замена rate limiting или WAF.

Смысл гораздо прозаичнее:

ночью мой телефон не должен становиться частью системы обнаружения ботов.

Почему Kalitka не должна становиться WAF

Во время разработки очень легко попасть в feature creep.

Раз у нас уже есть IP:

— давайте rate limits.

Раз есть GeoIP:

— давайте reputation score.

Раз есть headers:

— давайте SQL injection detection.

Раз есть reverse proxy:

— давайте TLS termination.

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

Я этого не хочу.

Сейчас граница проекта выглядит примерно так:

Kalitka должна:

  • решить, нужно ли спрашивать владельца;

  • запомнить решение;

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

  • автоматически отказывать явно нежелательным;

  • передавать остальных на обычную авторизацию приложения.

Kalitka не должна:

  • искать malware;

  • анализировать payload;

  • предотвращать DDoS;

  • заменять OAuth/OIDC provider;

  • хранить пользователей организации;

  • заменять авторизацию backend-приложения.

Чем меньше security-продукт, тем проще понять, что именно он гарантирует.

А понимание security boundary зачастую важнее количества функций.

Отдельная проблема — polling

Waiting page должна как-то узнать:

администратор уже нажал Allow?

Telegram не умеет позвонить обратно в браузер пользователя.

Поэтому страница polling'ом спрашивает:

/wait/status?id=...

Для обычного браузера ничего особенного.

Но CrowdSec, fail2ban и другие behaviour-based механизмы могут увидеть:

GET
GET
GET
GET
GET

и решить, что кто-то занимается crawling.

Особенно весело, если бан применяется на уровне общего Traefik entrypoint.

Тогда пользователь пытается получить доступ к одной Grafana, а в результате оказывается заблокирован вообще для всех сайтов за этим reverse proxy.

Поэтому /wait приходится учитывать в правилах внешней защиты.

Это хороший пример того, как два абсолютно разумных security-механизма в комбинации могут создать совершенно неразумное поведение.

SPA тоже умеют удивлять

Есть ещё один corner case.

Обычный сайт открывается:

browser → GET → redirect → gate

Всё понятно.

SPA может жить в уже закешированном shell и обращаться к backend через background fetch.

Теперь вместо API response прилетает:

302 → gate

JS этого совершенно не ожидал.

Результат может выглядеть как:

  • blank page;

  • странная ошибка;

  • вечный spinner.

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

Технически это можно обходить.

Но опять возникает вопрос: стоит ли усложнять маленький инструмент ради сценария, который можно решить другим способом?

Что получилось в итоге

Сейчас Kalitka умеет:

  • работать как forwardAuth;

  • спрашивать разрешение через Telegram;

  • approve / deny;

  • хранить allow/block lists;

  • блокировать IP, identity и country;

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

  • использовать Google Sign-In;

  • включать защиту отдельно для каждого host;

  • временно уходить в mute;

  • использовать подписанные sessions;

  • работать за trusted reverse proxies;

  • корректно учитывать X-Forwarded-For;

  • проверять допустимость target host;

  • fail closed;

  • работать в Docker;

  • интегрироваться с Traefik.

Основной код написан на .NET.

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

Следующий этап — автоматические security и integration tests.

Потому что для такого проекта тест:

known IP → 200

не особенно интересен.

Гораздо интереснее:

attacker sends forged X-Forwarded-For
→ still denied

или:

request comes directly, bypassing trusted proxy
→ forwarded headers ignored

или:

cookie valid but for another host
→ denied

или:

Telegram webhook secret wrong
→ 403

Security-код становится намного спокойнее развивать, когда подобные свойства системы существуют не только в голове разработчика.

Именно такое приветствие видит пользователь перед калиткой

Именно такое приветствие видит пользователь перед калиткой

А вот тут мы решаем пускать или нет.

А вот тут мы решаем пускать или нет.

зачем вообще это выкладывать в Open Source?

Изначально Kalitka была просто маленькой штукой для собственного infrastructure stack.

Но мне нравятся именно такие open-source проекты.

Не очередная универсальная enterprise platform.

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

ага, вот здесь принимается решение, вот здесь подписывается cookie, вот здесь проверяется proxy.

Security через «никто не способен разобраться в этих 800 тысячах строк» мне нравится значительно меньше.

Проект распространяется под MIT.

Репозиторий:

https://github.com/everycore-net/kalitka

Если кто-то использует похожую модель перед своими внутренними сервисами — мне особенно интересно, какие corner cases я ещё не встретил.

Потому что главный урок этого маленького проекта пока такой:

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

И, пожалуй, именно ради этого Kalitka оказалась для меня гораздо интереснее первоначальной задачи «спрятать Grafana от интернета».

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.