package http import ( "context" "errors" "log/slog" "net" "net/http" "net/netip" "git.vakhrushev.me/av/transcriber/internal/contract" ) // Заголовки, которыми обратный прокси называет пришедшего. // // Имена нормативны: смена имени молча перестаёт узнавать всех, а проверка, // которая сама ставит и сама читает своё имя, этого не замечает. Контур уже // пишет эти имена соседним сервисам, и настройкой они не делаются: второе место, // где их можно написать неверно, выгоды не даёт. const ( LoginHeader = "Remote-User" NameHeader = "Remote-Name" EmailHeader = "Remote-Email" ) // ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего. // Читает его только ограничитель частоты: барьером узнавания он не служит и // служить не может — кто пришёл, решает адрес самого соединения. const ForwardedForHeader = "X-Forwarded-For" // accountKey — ключ, под которым узнанная учётная запись живёт в контексте // запроса. Свой тип, а не строка: чужой ключ с тем же текстом иначе перезаписал // бы значение. type accountKey struct{} // AccountOf отдаёт учётную запись, от имени которой идёт запрос. Второе значение // ложно у неузнанного. func AccountOf(r *http.Request) (*contract.UserAccount, bool) { account, ok := r.Context().Value(accountKey{}).(*contract.UserAccount) return account, ok } // withAccount кладёт узнанную учётную запись в контекст запроса. func withAccount(r *http.Request, account *contract.UserAccount) *http.Request { return r.WithContext(context.WithValue(r.Context(), accountKey{}, account)) } // TrustedHeaderIdentity узнаёт пришедшего по заголовку доверенного источника. // // # Область // // Слой вешается на цепочку корня приложения и только на неё. Область поэтому // выводится из объявленного адресного пространства сервиса, а не перечисляется // вторым списком: корень, переехавший в перечне, уносит слой с собой. // // Сужение закрывает вещь, которая от смены хранилища не зависит: узнавание не // срабатывает на пробе здоровья, на метриках и на ресурсах приложения. Иначе // запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с // новым именем — записи в неё. // // # Чего слой не делает // // Отказа он не выдаёт: отказ приходит там, где приходил всегда, — требованием // учётной записи. Исключение одно — отказ базы: он кончается отказом сервиса, а // не молчаливым проходом неузнанным, иначе человек увидел бы отказ входа там, // где легла база. func TrustedHeaderIdentity( users contract.UserRepository, trusted []netip.Prefix, logger *slog.Logger, ) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() } return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Более одного значения — не выбор, а отказ. Прокси, настроенный // добавлять заголовок вместо замены, оставляет рядом со своим // значением присланное анонимом, и умолчание «берём первое» отдало // бы вход анониму. values := r.Header.Values(LoginHeader) if len(values) != 1 { if len(values) > 1 { // Уровень предупреждающий: два значения означают прокси, // который заголовок **добавляет** вместо замены, — то есть // ровно ту поломку контура, которую модель угроз называет // главной. Отладочным уровнем она в бою не видна вовсе. logger.Warn("Request carries more than one login header", "http.peer_addr", r.RemoteAddr, "capability", "access", "transport", "http") } else { // Заголовка нет вовсе. В этом контуре это значит, что прокси // никого не назвал; строка отладочная, потому что случай // штатный — так выглядит и человек, которому провайдер // отказал. logger.Debug("Request carries no login header", "http.peer_addr", r.RemoteAddr, "capability", "access", "transport", "http") } next.ServeHTTP(w, r) return } peer, ok := peerAddress(r.RemoteAddr) if !ok || !isTrusted(trusted, peer) { // Уровень предупреждающий, а не отладочный, и это решение о // цене. Заголовок с недоверенного адреса в этом контуре — не // рутина: контейнер портов наружу не публикует, снаружи всё // приходит прокси, то есть с доверенного адреса. Значит либо // перечень задан неверно, либо кто-то оказался внутри сети — и // то и другое владелец обязан увидеть. // // Значение заголовка при этом в журнал не идёт: оно целиком // задаётся тем, кто шлёт запрос. Адрес пира идёт — по нему // видно, чья это поломка: своя (перечень) или контура (прокси // заголовка не ставит). logger.Warn("Login header came from an untrusted peer", "http.peer_addr", r.RemoteAddr, "capability", "access", "transport", "http") next.ServeHTTP(w, r) return } account, created, err := users.EnsureUser(contract.Identity{ Login: values[0], Name: r.Header.Get(NameHeader), Email: r.Header.Get(EmailHeader), }) if err != nil { if errors.Is(err, contract.ErrLoginNotAcceptable) { // Негодный логин — это негодный ввод, а не поломка сервиса: // пустой заголовок прокси шлёт штатно там, где никого не // назвал. Уровень поэтому отладочный, и запрос идёт дальше // неузнанным. logger.Debug("Login header value is not acceptable", "http.peer_addr", r.RemoteAddr, "capability", "access", "transport", "http") next.ServeHTTP(w, r) return } logger.Error("Failed to resolve account by login header", "error", err, "capability", "access", "transport", "http") fail(w, err) return } if created { // Заведение учётной записи — событие, и владелец обязан его // видеть: иначе «никто не заходил» неотличимо от «завелось // двадцать», а прокси, пропустивший чужой заголовок, не // оставляет следа вовсе. Убрать заведённую запись потом нечем — // учётная запись с записями не удаляется. // // Значение заголовка в строку не идёт: им довольно назваться, // чтобы стать этим человеком. Идут адрес пира и идентификатор // записи — оба выданы не спрашивающим. logger.Info("Account created from login header", "http.peer_addr", r.RemoteAddr, "account_id", account.ID, "capability", "access", "transport", "http") } next.ServeHTTP(w, withAccount(r, account)) }) } } // RequireUser — слой предъявления адресов приложения. // // Отказ наступает **до чтения тела**: запись, за которую не заплатит узнанный // отправитель, не должна попасть даже в память, а позже пришлось бы убирать уже // уложенный файл — чего сервис не умеет вовсе. // // Ветви «узнан, а учётной записи нет» здесь больше нет: узнавание заводит // учётную запись само, и предъявителя без неё не бывает. func RequireUser() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if _, ok := AccountOf(r); !ok { fail(w, contract.ErrUnauthorized) return } next.ServeHTTP(w, r) }) } } // 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 }