package http import ( "errors" "log/slog" "net" "net/netip" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/hook" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" ) // Заголовки, которыми обратный прокси называет пришедшего. // // Имена нормативны — ровно как было нормативно имя куки сессии, и по той же // причине: смена имени молча перестаёт узнавать всех, а проверка, которая сама // ставит и сама читает своё имя, этого не замечает. Контур уже пишет эти имена // соседним сервисам, и настройкой они не делаются: второе место, где их можно // написать неверно, выгоды не даёт. const ( LoginHeader = "Remote-User" NameHeader = "Remote-Name" EmailHeader = "Remote-Email" ) // FileTokenPath — адрес, которым хранилище выдаёт короткий токен файла. // // Он лежит в пространстве хранилища, а не приложения, и потому назван здесь // поимённо: без узнавания на нём файл записи недостижим для браузера вовсе — // порядок «узнавание → токен файла → ссылка» обрывается на первом шаге. const FileTokenPath = StorageRoot + "/files/token" // TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника. // // # Область // // Слой вешается корневым — иначе к адресу выдачи файлового токена его не // привязать: тот принадлежит роутеру хранилища, и группой его не накрыть. Но // узнаёт он **только на объявленной области**: корень приложения плюс этот // адрес. Область выводится из перечня адресного пространства, а не пишется // вторым списком. // // Сужение здесь не бережливость, а барьер. Ключ учётной записи лежит обычной // колонкой коллекции пользователей, и узнавание на всей поверхности хранилища // дало бы узнанному переписать себе ключ на чужое имя — а первое обращение // настоящего владельца этого имени попало бы в чужую запись вместе со всем // архивом. Схема закрывает этот путь и со своей стороны, правилами коллекции; // два барьера здесь именно потому, что прежний был один и держался на // случайности — на том, что браузер сам не шлёт заголовка авторизации. // // Второе следствие: узнавание не срабатывает на пробе здоровья, на метриках и // на ресурсах приложения. Иначе запрос за каждой картинкой стоил бы обращения к // базе, а первый такой запрос с новым именем — записи в неё. // // # Кто побеждает // // Учётная запись ставится, только когда её ещё нет, — то есть когда слой чтения // токена никого не нашёл. Владелец панели предъявляет свой токен, и подмена его // учётной записью пользователя отобрала бы у него панель посреди работы. // Протухший и негодный токен предъявленными не считаются: библиотека их не // прочитала, `e.Auth` пуст, и запрос узнаётся заголовком. // // # Чего слой не делает // // Отказа он не выдаёт. Проба здоровья, метрики и разметка приложения открыты // неузнанному, и отказ в слое закрыл бы наблюдение за сервисом всякому, кто // пришлёт заголовок. Отказ приходит там, где приходил всегда, — требованием // учётной записи на адресах приложения. // // Исключение одно: отказ **хранилища**. Он кончается отказом сервиса, а не // молчаливым проходом неузнанным, — иначе человек увидел бы отказ входа там, где // легла база. func TrustedHeaderIdentity( app core.App, mounts []Mount, trusted []netip.Prefix, logger *slog.Logger, ) *hook.Handler[*core.RequestEvent] { if logger == nil { logger = slog.Default() } return &hook.Handler[*core.RequestEvent]{ Id: "transcriberTrustedHeaderIdentity", // **За ограничителем частоты, а не перед ним.** Узнавание читает базу, а // на новом имени ещё и пишет в неё; поставленное раньше ограничителя, оно // работало на запросах, которые тот уже отверг. Замер: сто двадцать // запросов выбирают бюджет, следующие пятьдесят с новыми именами // получают отказ — и заводят пятьдесят учётных записей, которые потом не // убираются ничем. // // Порядок целиком: чтение токена (−1020) → ограничитель (−1000) → // узнавание (−999) → требование учётной записи (−998) → предел тела // (−990). Требование стоит перед пределом тела намеренно: отказ // неузнанному обязан наступать до чтения тела. Priority: apis.DefaultRateLimitMiddlewarePriority + 1, Func: func(e *core.RequestEvent) error { if e.Auth != nil || !underIdentifiedArea(mounts, e.Request.URL.Path) { return e.Next() } // Более одного значения — не выбор, а отказ. Прокси, настроенный // добавлять заголовок вместо замены, оставляет рядом со своим // значением присланное анонимом, и умолчание «берём первое» отдало // бы вход анониму. values := e.Request.Header.Values(LoginHeader) if len(values) != 1 { if len(values) > 1 { // Уровень предупреждающий: два значения означают прокси, // который заголовок **добавляет** вместо замены, — то есть // ровно ту поломку контура, которую модель угроз называет // главной. Отладочным уровнем она в бою не видна вовсе: // боевой уровень журнала информационный. logger.Warn("Request carries more than one login header", "http.peer_addr", e.Request.RemoteAddr, "capability", "access", "transport", "http") } else { // Заголовка нет вовсе. В этом контуре это значит, что прокси // никого не назвал; строка отладочная, потому что случай // штатный — так выглядит и человек, которому провайдер // отказал. logger.Debug("Request carries no login header", "http.peer_addr", e.Request.RemoteAddr, "capability", "access", "transport", "http") } return e.Next() } peer, ok := peerAddress(e.Request.RemoteAddr) if !ok || !isTrusted(trusted, peer) { // Уровень предупреждающий, а не отладочный, и это решение о // цене. Заголовок с недоверенного адреса в этом контуре — не // рутина: контейнер портов наружу не публикует, снаружи всё // приходит прокси, то есть с доверенного адреса. Значит либо // перечень задан неверно, либо кто-то оказался внутри сети — и // то и другое владелец обязан увидеть. // // Значение заголовка при этом в журнал не идёт: оно целиком // задаётся тем, кто шлёт запрос. Адрес пира идёт — по нему // видно, чья это поломка: своя (перечень) или контура (прокси // заголовка не ставит). logger.Warn("Login header came from an untrusted peer", "http.peer_addr", e.Request.RemoteAddr, "capability", "access", "transport", "http") return e.Next() } record, created, err := pbrepo.EnsureUser(app, pbrepo.Identity{ Login: values[0], Name: e.Request.Header.Get(NameHeader), Email: e.Request.Header.Get(EmailHeader), }) if err != nil { if errors.Is(err, pbrepo.ErrLoginNotAcceptable) { // Негодный логин — это негодный ввод, а не поломка сервиса: // пустой заголовок прокси шлёт штатно там, где никого не // назвал. Уровень поэтому отладочный, и запрос идёт дальше // неузнанным. logger.Debug("Login header value is not acceptable", "http.peer_addr", e.Request.RemoteAddr, "capability", "access", "transport", "http") return e.Next() } logger.Error("Failed to resolve account by login header", "error", err, "capability", "access", "transport", "http") return fail(e, err) } if created { // Заведение учётной записи — событие, и владелец обязан его // видеть: иначе «никто не заходил» неотличимо от «завелось // двадцать», а прокси, пропустивший чужой заголовок, не // оставляет следа вовсе. Убрать заведённую запись потом нечем — // учётная запись с записями не удаляется. // // Значение заголовка в строку не идёт: им довольно назваться, // чтобы стать этим человеком. Идут адрес пира и идентификатор // записи — оба выданы не спрашивающим. logger.Info("Account created from login header", "http.peer_addr", e.Request.RemoteAddr, "account_id", record.Id, "capability", "access", "transport", "http") } e.Auth = record return e.Next() }, } } // underIdentifiedArea говорит, узнаётся ли пришедший на этом пути. // // Область — корень приложения из перечня адресного пространства плюс адрес // выдачи файлового токена. Корень берётся из перечня, а не литералом: перечень // объявлен единой точкой адресного пространства, и записанный здесь второй раз // он разошёлся бы с ней молча. func underIdentifiedArea(mounts []Mount, requestPath string) bool { if requestPath == FileTokenPath { return true } for _, mount := range mounts { if mount.Path == AppRoot { return mount.Covers(requestPath) } } return false } // peerAddress достаёт адрес того, кто открыл соединение. // // Берётся именно он, а не пересылаемый заголовок: значением пересылаемого // распоряжается тот, кто шлёт запрос, и барьер, подделываемый той же строкой, // которой он обходится, не барьер вовсе. func peerAddress(remoteAddr string) (netip.Addr, bool) { host, _, err := net.SplitHostPort(remoteAddr) if err != nil { // Адрес без порта — законная форма у некоторых слушателей. host = remoteAddr } addr, err := netip.ParseAddr(host) if err != nil { return netip.Addr{}, false } // Адрес IPv4, приехавший в оболочке IPv6, сверяется с перечнем как IPv4: // иначе `127.0.0.1` в перечне не совпал бы с `::ffff:127.0.0.1` у пира. return addr.Unmap(), true } func isTrusted(trusted []netip.Prefix, peer netip.Addr) bool { for _, network := range trusted { if network.Contains(peer) { return true } } return false }