вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user