локальный вход задаётся конфигом: заголовки подставляет сам сервис
- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug: заголовки входа подставляет слой транспорта, второго процесса локальный запуск больше не требует - подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает сам сервис - адресного предохранителя нет по решению владельца — цена названа в ADR и в модели угроз
This commit is contained in:
@@ -62,6 +62,16 @@ type ServerConfig struct {
|
||||
Port int `toml:"port"`
|
||||
ShutdownTimeout int `toml:"shutdown_timeout"`
|
||||
ForceShutdownTimeout int `toml:"force_shutdown_timeout"`
|
||||
// Debug — предохранитель отладочного запуска. Умолчание — «выключено»:
|
||||
// отсутствие ключа читается как боевой прогон, а не как отладочный.
|
||||
//
|
||||
// Означает он одно: прогон идёт на машине разработчика, и сервису позволено
|
||||
// подставить то, что в бою даёт окружение. Сегодня подставляется ровно одна
|
||||
// вещь — заголовки входа, — и перечень следствий закрыт: уровня журнала,
|
||||
// текстов внутренних отказов, ограничителя частоты, подмены распознавателя и
|
||||
// проверок старта признак не касается. Новое следствие вешается на него
|
||||
// только отдельной нормой спеки `access`.
|
||||
Debug bool `toml:"debug"`
|
||||
}
|
||||
|
||||
// StorageConfig — хранилище сервиса: каталог данных и числа его базы.
|
||||
@@ -121,6 +131,18 @@ type AuthConfig struct {
|
||||
// пересылаемым распоряжается тот, кто шлёт запрос, и барьер, подделываемый
|
||||
// той же строкой, которой он обходится, не барьер вовсе.
|
||||
TrustedProxies []string `toml:"trusted_proxies"`
|
||||
|
||||
// TestHeaders — имитация заголовков, которые в бою ставит обратный прокси.
|
||||
// Ключ — имя заголовка, значение — то, чем сервис назовёт пришедшего сам.
|
||||
//
|
||||
// Работает только при включённом предохранителе `[server] debug`, и
|
||||
// заполненная секция без него роняет старт: состояние «имитация есть,
|
||||
// предохранителя нет» не читается никак, а обе его прочтения — поломка.
|
||||
//
|
||||
// Умолчание — пустая секция. Непустой она считается по наличию ключа, каким
|
||||
// бы ни было его значение: `Remote-User = ""` — заполненная имитация, а не
|
||||
// отсутствие её.
|
||||
TestHeaders map[string]string `toml:"test_headers"`
|
||||
}
|
||||
|
||||
// TrustedNetworks разбирает перечень доверенных адресов.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/textproto"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.vakhrushev.me/av/transcriber/internal/entity"
|
||||
)
|
||||
|
||||
// Имена ключей в отказах старта. Литералы по одному на пакет: два разошлись бы
|
||||
// молча, и владелец искал бы в конфиге ключ, которого там нет.
|
||||
const (
|
||||
debugKey = "[server] debug"
|
||||
testHeadersKey = "[auth.test_headers]"
|
||||
)
|
||||
|
||||
// HeaderSubstitution отдаёт **что** сервис подставит запросу вместо заголовков
|
||||
// обратного прокси — и этим же перечнем отвечает на «подставляет ли»: пустой
|
||||
// перечень значит «не подставляет».
|
||||
//
|
||||
// Признака вторым значением нет намеренно. Предикат, посчитанный дважды —
|
||||
// булевым ответом здесь и непустотой карты у потребителя, — разошёлся бы молча,
|
||||
// и сервис либо подставлял бы молча, либо молча не подставлял. Представление
|
||||
// одно: непустота карты, и судят её все одинаково — проверка старта, строка
|
||||
// журнала при старте и слой подстановки.
|
||||
//
|
||||
// Ключи возвращаемой карты приведены к каноническому виду имени заголовка: в
|
||||
// HTTP имя нечувствительно к регистру, а ключ TOML чувствителен.
|
||||
//
|
||||
// Перечень пуст и тогда, когда предохранитель включён, а имитация пуста: сам по
|
||||
// себе признак ничего не включает.
|
||||
func (c *Config) HeaderSubstitution() map[string]string {
|
||||
if !c.Server.Debug || len(c.Auth.TestHeaders) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
headers := make(map[string]string, len(c.Auth.TestHeaders))
|
||||
for name, value := range c.Auth.TestHeaders {
|
||||
headers[textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name))] = value
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
// ValidateTestHeaders судит настройки отладочного входа на старте, до приёма
|
||||
// трафика: настройка, отданная на честность выкладки, проверяется только тем,
|
||||
// что чужой архив уже уехал не тому.
|
||||
//
|
||||
// Имена заголовков приходят доводом, а не читаются отсюда: дом у них один —
|
||||
// константы транспорта, — а пакет настроек транспорта не знает и знать не
|
||||
// должен. Обратное ребро сделало бы `cmd/devtools`, которому нужен один разбор
|
||||
// конфига, линкующим всю поверхность HTTP.
|
||||
//
|
||||
// Отказов четыре, и каждый закрывает своё «не читается никак»:
|
||||
//
|
||||
// - имитация заполнена, предохранитель выключен. Либо человек забыл включить
|
||||
// предохранитель и будет искать поломку везде, кроме одного ключа, либо
|
||||
// забыл убрать имитацию из боевого файла — и тогда до открытого архива
|
||||
// остаётся одно слово;
|
||||
// - два ключа секции дают одно каноническое имя заголовка. `Remote-User` и
|
||||
// `remote-user` для TOML — два ключа, для HTTP — одно имя, и одно из двух
|
||||
// значений потерялось бы молча;
|
||||
// - имитация называет имя, которого сервис не читает. Опечатка `Remote-Usr`
|
||||
// иначе кончается сервисом, который никого не узнаёт, без единого следа;
|
||||
// - имитация непуста, а годного логина в ней нет. Секция с одним
|
||||
// `Remote-Email` подняла бы сервис, который подставит почту, удалит логин и
|
||||
// не узнает никого.
|
||||
//
|
||||
// Значений отказы не называют: логин — ключ к чужому архиву, и запрет печатать
|
||||
// его действует на подставленное значение наравне с пришедшим.
|
||||
func (c *Config) ValidateTestHeaders(accepted []string, loginHeader string) error {
|
||||
headers := c.HeaderSubstitution()
|
||||
if len(headers) == 0 {
|
||||
if len(c.Auth.TestHeaders) > 0 {
|
||||
return fmt.Errorf(
|
||||
"auth: секция %s заполнена, а предохранитель %s выключен: "+
|
||||
"либо включите предохранитель, либо уберите имитацию",
|
||||
testHeadersKey, debugKey,
|
||||
)
|
||||
}
|
||||
|
||||
// Включённый предохранитель при пустой имитации законен: сам по себе он
|
||||
// ничего не включает.
|
||||
return nil
|
||||
}
|
||||
|
||||
// Два ключа, различающиеся только регистром, дали бы одно имя заголовка и
|
||||
// одно значение — второе потерялось бы молча.
|
||||
if len(headers) != len(c.Auth.TestHeaders) {
|
||||
return fmt.Errorf(
|
||||
"auth: в секции %s два ключа называют один заголовок: "+
|
||||
"имя заголовка нечувствительно к регистру, и одно из значений потерялось бы молча",
|
||||
testHeadersKey,
|
||||
)
|
||||
}
|
||||
|
||||
known := make(map[string]bool, len(accepted))
|
||||
for _, name := range accepted {
|
||||
known[textproto.CanonicalMIMEHeaderKey(name)] = true
|
||||
}
|
||||
|
||||
unknown := make([]string, 0, len(headers))
|
||||
for name := range headers {
|
||||
if !known[name] {
|
||||
unknown = append(unknown, name)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
// Порядок перебора карты свой у каждого прогона, а отказ старта читает
|
||||
// человек: без сортировки один и тот же конфиг давал бы разный текст.
|
||||
sort.Strings(unknown)
|
||||
return fmt.Errorf(
|
||||
"auth: секция %s называет заголовок, которого сервис не читает: %s; принимаются %s",
|
||||
testHeadersKey, strings.Join(unknown, ", "), strings.Join(accepted, ", "),
|
||||
)
|
||||
}
|
||||
|
||||
login, named := headers[textproto.CanonicalMIMEHeaderKey(loginHeader)]
|
||||
if !named {
|
||||
return fmt.Errorf(
|
||||
"auth: секция %s не называет ключа %s: сервис подставил бы всё прочее и не узнал бы никого",
|
||||
testHeadersKey, loginHeader,
|
||||
)
|
||||
}
|
||||
if _, ok := entity.AcceptProviderLogin(login); !ok {
|
||||
return fmt.Errorf(
|
||||
"auth: значение ключа %s в секции %s не годится в логин: "+
|
||||
"пустое, из одних пробельных знаков, длиннее %d знаков либо с управляющими знаками",
|
||||
loginHeader, testHeadersKey, entity.MaxProviderLoginLength,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
httpcontroller "git.vakhrushev.me/av/transcriber/internal/controller/http"
|
||||
)
|
||||
|
||||
// Проверки этого файла судят требование «Настройка, открывающая вход всем,
|
||||
// роняет старт». Предмет у них один: сервис, поднявшийся на настройках, при
|
||||
// которых отладочный вход становится открытым входом либо не работает вовсе.
|
||||
//
|
||||
// Имена заголовков берутся у транспорта, а не выписываются здесь: дом у них
|
||||
// один, и проверка со своим списком зеленела бы на разошедшемся коде.
|
||||
|
||||
func acceptedHeaderNames() []string { return httpcontroller.IdentityHeaderNames() }
|
||||
|
||||
// validateTestHeaders зовёт проверку так же, как её зовёт точка входа.
|
||||
func validateTestHeaders(cfg *Config) error {
|
||||
return cfg.ValidateTestHeaders(acceptedHeaderNames(), httpcontroller.LoginHeader)
|
||||
}
|
||||
|
||||
// debugConfig собирает настройки отладочного запуска: предохранитель и имитация.
|
||||
func debugConfig(debug bool, headers map[string]string) *Config {
|
||||
cfg := defaultConfig()
|
||||
cfg.Server.Debug = debug
|
||||
cfg.Auth.TrustedProxies = []string{"127.0.0.1"}
|
||||
cfg.Auth.TestHeaders = headers
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Первый отказ: имитация заполнена, предохранителя нет. Состояние не читается
|
||||
// никак — либо человек забыл включить предохранитель, либо забыл убрать
|
||||
// имитацию из боевого файла, и до открытого архива остаётся одно слово.
|
||||
func TestTestHeadersWithoutDebugFailsStartup(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(false, map[string]string{
|
||||
httpcontroller.LoginHeader: "local",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("имитация без предохранителя принята: сервис поднялся бы никого не узнающим")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[server] debug") {
|
||||
t.Fatalf("имя ключа предохранителя не названо, чинить нечего: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустое значение — заполненная имитация, а не отсутствие её: человек, написавший
|
||||
// ключ, имитацию завёл, и пустое значение у него вторая поломка, а не первая.
|
||||
func TestEmptyLoginValueCountsAsFilledSection(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(false, map[string]string{
|
||||
httpcontroller.LoginHeader: "",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("секция с пустым значением сочтена пустой")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "[server] debug") {
|
||||
t.Fatalf("имя ключа предохранителя не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Второй отказ: имя, которого сервис не читает. Опечатка иначе кончается
|
||||
// сервисом, который никого не узнаёт, без единого следа.
|
||||
func TestUnknownHeaderNameFailsStartup(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(true, map[string]string{
|
||||
httpcontroller.LoginHeader: "local",
|
||||
"Remote-Usr": "local",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("неизвестное имя заголовка принято")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, "Remote-Usr") {
|
||||
t.Fatalf("неизвестное имя не названо: %v", err)
|
||||
}
|
||||
for _, name := range acceptedHeaderNames() {
|
||||
if !strings.Contains(message, name) {
|
||||
t.Fatalf("принимаемое имя %s не названо, чинить нечего: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Третий отказ, первая его половина: ключа логина нет вовсе. Такая секция
|
||||
// подняла бы сервис, который подставит почту, удалит логин и не узнает никого.
|
||||
func TestSectionWithoutLoginKeyFailsStartup(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(true, map[string]string{
|
||||
httpcontroller.EmailHeader: "local@example.com",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("имитация без ключа логина принята")
|
||||
}
|
||||
if !strings.Contains(err.Error(), httpcontroller.LoginHeader) {
|
||||
t.Fatalf("имя недостающего ключа не названо: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Третий отказ, вторая половина: логин судится **тем же** приёмом, каким
|
||||
// узнавание судит пришедшее значение. Иначе сервис поднимается, ставит
|
||||
// заголовок, получает отказ приёма и отвечает неузнанным на всё.
|
||||
func TestUnacceptableLoginValueFailsStartup(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"пустое": "",
|
||||
"пробельное": " ",
|
||||
"сверх предела": strings.Repeat("x", 300),
|
||||
"с управляющим знаком": "loc\x00al",
|
||||
}
|
||||
|
||||
for name, login := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(true, map[string]string{
|
||||
httpcontroller.LoginHeader: login,
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("негодное значение логина принято")
|
||||
}
|
||||
message := err.Error()
|
||||
if !strings.Contains(message, httpcontroller.LoginHeader) {
|
||||
t.Fatalf("имя ключа не названо, чинить нечего: %v", err)
|
||||
}
|
||||
// Значение в отказ не идёт: логин — ключ к чужому архиву.
|
||||
if login != "" && strings.Contains(message, login) {
|
||||
t.Fatalf("значение логина уехало в отказ старта: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Два ключа, различающиеся регистром, назвали бы один заголовок: имя заголовка
|
||||
// нечувствительно к регистру, а ключ TOML чувствителен, и одно из значений
|
||||
// потерялось бы молча.
|
||||
func TestDuplicateHeaderKeyFailsStartup(t *testing.T) {
|
||||
err := validateTestHeaders(debugConfig(true, map[string]string{
|
||||
"Remote-User": "one",
|
||||
"remote-user": "two",
|
||||
}))
|
||||
if err == nil {
|
||||
t.Fatal("два ключа на один заголовок приняты: одно значение потерялось бы молча")
|
||||
}
|
||||
}
|
||||
|
||||
// Законный случай: предохранитель включён, имитация пуста. Сам по себе признак
|
||||
// ничего не включает, и перечень доверенных адресов ему не судья.
|
||||
func TestDebugWithoutTestHeadersStarts(t *testing.T) {
|
||||
cfg := debugConfig(true, nil)
|
||||
cfg.Auth.TrustedProxies = []string{"172.20.0.0/24"}
|
||||
|
||||
if err := validateTestHeaders(cfg); err != nil {
|
||||
t.Fatalf("включённый предохранитель при пустой имитации уронил старт: %v", err)
|
||||
}
|
||||
if len(cfg.HeaderSubstitution()) > 0 {
|
||||
t.Fatal("пустая имитация включила подстановку")
|
||||
}
|
||||
}
|
||||
|
||||
// Законный случай главный: конфиг сегодняшнего дня, не называющий ни одного
|
||||
// нового ключа, ведёт себя ровно как вёл.
|
||||
func TestConfigWithoutNewKeysStartsUnchanged(t *testing.T) {
|
||||
path := writeConfig(t, "[auth]\ntrusted_proxies = [\"172.20.0.0/24\"]\n"+validConfigBody)
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("конфиг без новых ключей не прочитан: %v", err)
|
||||
}
|
||||
if cfg.Server.Debug {
|
||||
t.Fatal("отсутствие ключа предохранителя прочитано как «включено»")
|
||||
}
|
||||
if len(cfg.Auth.TestHeaders) != 0 {
|
||||
t.Fatalf("отсутствие секции имитации прочитано как заполненная: %v", cfg.Auth.TestHeaders)
|
||||
}
|
||||
if err := validateTestHeaders(cfg); err != nil {
|
||||
t.Fatalf("конфиг без новых ключей уронил старт: %v", err)
|
||||
}
|
||||
if cfg.HeaderSubstitution() != nil {
|
||||
t.Fatal("конфиг без новых ключей включил подстановку")
|
||||
}
|
||||
}
|
||||
|
||||
// Негодное значение предохранителя роняет старт разбором, а не читается как
|
||||
// «включено»: ошибка разбора не вправе открывать вход.
|
||||
func TestMalformedDebugValueFailsLoad(t *testing.T) {
|
||||
path := writeConfig(t, "[server]\ndebug = \"yes\"\n"+validConfigBody)
|
||||
|
||||
if _, err := LoadConfig(path); err == nil {
|
||||
t.Fatal("строка вместо булева значения принята")
|
||||
}
|
||||
}
|
||||
|
||||
// Ключи имитации приезжают из файла, а на выходе приведены к каноническому виду
|
||||
// имени заголовка: в HTTP имя нечувствительно к регистру, а ключ TOML — нет.
|
||||
func TestHeaderSubstitutionReadsSectionAndCanonicalizes(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
[server]
|
||||
debug = true
|
||||
|
||||
[auth]
|
||||
trusted_proxies = ["127.0.0.1"]
|
||||
|
||||
[auth.test_headers]
|
||||
remote-user = "local"
|
||||
REMOTE-EMAIL = "local@example.com"
|
||||
`+validConfigBody)
|
||||
|
||||
cfg, err := LoadConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("конфиг с имитацией не прочитан: %v", err)
|
||||
}
|
||||
if err := validateTestHeaders(cfg); err != nil {
|
||||
t.Fatalf("годная имитация уронила старт: %v", err)
|
||||
}
|
||||
|
||||
headers := cfg.HeaderSubstitution()
|
||||
if len(headers) == 0 {
|
||||
t.Fatal("заполненная имитация при включённом предохранителе не включила подстановку")
|
||||
}
|
||||
if headers[httpcontroller.LoginHeader] != "local" {
|
||||
t.Fatalf("логин не приведён к каноническому имени заголовка: %v", headers)
|
||||
}
|
||||
if headers[httpcontroller.EmailHeader] != "local@example.com" {
|
||||
t.Fatalf("адрес почты не приведён к каноническому имени заголовка: %v", headers)
|
||||
}
|
||||
if _, named := headers[httpcontroller.NameHeader]; named {
|
||||
t.Fatalf("не названный секцией заголовок появился в перечне: %v", headers)
|
||||
}
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func TestPanickingHandlerAnswersOurFailureFormAndProcessLives(t *testing.T) {
|
||||
panic("шаг обработчика упал")
|
||||
})
|
||||
mounts := ServiceMounts(
|
||||
AppChain(panicking, users, testTrustedNetworks(t), logger),
|
||||
AppChain(panicking, users, testTrustedNetworks(t), nil, logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||
|
||||
@@ -23,6 +23,15 @@ const (
|
||||
EmailHeader = "Remote-Email"
|
||||
)
|
||||
|
||||
// IdentityHeaderNames отдаёт имена заголовков входа целиком, в одном порядке.
|
||||
//
|
||||
// Перечисление тройки живёт здесь и только здесь. Всякий, кому нужен её состав —
|
||||
// слой подстановки, проверка старта, строка журнала, — берёт его отсюда: второй
|
||||
// список разошёлся бы с первым молча, а имена нормативны.
|
||||
func IdentityHeaderNames() []string {
|
||||
return []string{LoginHeader, NameHeader, EmailHeader}
|
||||
}
|
||||
|
||||
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
|
||||
// Читает его только ограничитель частоты: барьером узнавания он не служит и
|
||||
// служить не может — кто пришёл, решает адрес самого соединения.
|
||||
|
||||
@@ -10,28 +10,39 @@ import (
|
||||
|
||||
// AppChain одевает адреса приложения в их слои.
|
||||
//
|
||||
// Порядок один и он несущий: ограничитель частоты → узнавание → требование
|
||||
// учётной записи → обработчик.
|
||||
// Порядок один и он несущий: ограничитель частоты → подстановка заголовков →
|
||||
// узнавание → требование учётной записи → обработчик.
|
||||
//
|
||||
// - ограничитель стоит первым, потому что узнавание читает базу, а на новом
|
||||
// имени ещё и пишет в неё: поставленное раньше, оно работало бы на запросах,
|
||||
// которые ограничитель уже отверг, и поток отвергнутых обращений заводил бы
|
||||
// учётные записи, которые потом не убираются ничем;
|
||||
// - подстановка стоит перед узнаванием и после ограничителя: она правит
|
||||
// заголовки, а ограничитель считает по адресу спрашивающего, и заголовки
|
||||
// входа его не касаются;
|
||||
// - требование учётной записи стоит перед чтением тела: запись, за которую не
|
||||
// заплатит узнанный отправитель, не должна попасть даже в память, а позже
|
||||
// пришлось бы убирать уже уложенный файл — чего сервис не умеет вовсе.
|
||||
//
|
||||
// Область действия всех трёх — корень приложения, и берётся она из перечня
|
||||
// Подстановка — звено необязательное: пустой перечень значит, что отладочный
|
||||
// запуск выключен, и слой отдаёт цепочку нетронутой — следующее звено как есть.
|
||||
// Цепочка при этом ровно та же, что была до появления предохранителя, — лишнего
|
||||
// звена в бою нет. Судит эту пустоту сам слой, и предикат «подставляем ли» имеет
|
||||
// поэтому одно представление — непустоту перечня; здесь не решается ничего.
|
||||
//
|
||||
// Область действия всех — корень приложения, и берётся она из перечня
|
||||
// адресного пространства: цепочка вешается на корень целиком, вторым списком
|
||||
// адресов её не описывают.
|
||||
func AppChain(
|
||||
routes http.Handler,
|
||||
users contract.UserRepository,
|
||||
trusted []netip.Prefix,
|
||||
substitution map[string]string,
|
||||
logger *slog.Logger,
|
||||
) http.Handler {
|
||||
handler := RequireUser()(routes)
|
||||
handler = TrustedHeaderIdentity(users, trusted, logger)(handler)
|
||||
handler = SubstituteIdentityHeaders(substitution, trusted, logger)(handler)
|
||||
handler = RateLimit(trusted)(handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@ func TestRecordText_ReadFailureIsNotANotFound(t *testing.T) {
|
||||
)
|
||||
|
||||
mounts := ServiceMounts(
|
||||
AppChain(handler.Routes(), env.users, testTrustedNetworks(t), logger),
|
||||
AppChain(handler.Routes(), env.users, testTrustedNetworks(t), nil, logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
mux := BuildHandler(mounts, NewWebappHandler(builtDist(), true, logger), logger)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// SubstituteIdentityHeaders — слой отладочного запуска: ставит запросу заголовки
|
||||
// входа значениями из настроек.
|
||||
//
|
||||
// # Зачем он есть
|
||||
//
|
||||
// В бою заголовки ставит обратный прокси, сходивший к провайдеру. На машине
|
||||
// разработчика прокси нет, а браузер заголовков не ставит — и приложение
|
||||
// локально не открылось бы вовсе. Слой встаёт на место контура **внутри самого
|
||||
// сервиса**: второго процесса и второго порта для этого не нужно.
|
||||
//
|
||||
// # Чего слой не делает
|
||||
//
|
||||
// Он правит заголовки запроса и ничего больше: учётной записи не заводит,
|
||||
// отказов не выдаёт, в контекст не пишет. Узнавание получает запрос,
|
||||
// неотличимый от пришедшего с сервера, и отлаживается поэтому боевая ветка, а
|
||||
// не её отладочный двойник.
|
||||
//
|
||||
// # Что ему запрещено
|
||||
//
|
||||
// Подстановка — это настройка, которой сервис называет пришедшего сам, никого
|
||||
// не спросив. Ограничений поэтому три, и каждое отсекает свой способ отдать
|
||||
// чужой архив:
|
||||
//
|
||||
// - пустой перечень значит «подставлять нечего», и слой отдаёт цепочку
|
||||
// нетронутой: звено не встаёт в неё вовсе, а запрос доходит до узнавания
|
||||
// тем же, каким пришёл. Судится пустота здесь, и представление у предиката
|
||||
// «подставляем ли» одно на всех — непустота самого перечня. Иначе
|
||||
// включённый предохранитель при пустой имитации срезал бы `Remote-Email` у
|
||||
// запроса, пришедшего без `Remote-User`;
|
||||
// - подставляется только при **отсутствии** `Remote-User`. Пришедший заголовок
|
||||
// не подменяется и не дополняется: узнавание отвергает запрос с двумя
|
||||
// значениями, и слой, дописавший второе, превратил бы законный отказ в
|
||||
// отладочный проход;
|
||||
// - подставляется только с адреса из перечня доверенных, и судит адрес та же
|
||||
// функция, какой судит его узнавание. Второй сверщик разошёлся бы с первым
|
||||
// молча: узнавание разворачивает IPv4 в оболочке IPv6.
|
||||
//
|
||||
// Подставляя, слой распоряжается **всей** тройкой заголовков входа: названные
|
||||
// настройками ставит их значением, не названные удаляет. Запрос без
|
||||
// `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
|
||||
// источников — логин свой, почта чужая.
|
||||
func SubstituteIdentityHeaders(
|
||||
headers map[string]string,
|
||||
trusted []netip.Prefix,
|
||||
logger *slog.Logger,
|
||||
) func(http.Handler) http.Handler {
|
||||
// Подставлять нечего: цепочка слоёв остаётся ровно той, что в бою, —
|
||||
// следующее звено возвращается как есть, лишнего звена не появляется.
|
||||
if len(headers) == 0 {
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Решает **наличие** заголовка, а не его значение: пустое значение
|
||||
// штатно шлёт прокси там, где никого не назвал, и подстановка на
|
||||
// этом месте назвала бы человеком того, кому провайдер отказал.
|
||||
if len(r.Header.Values(LoginHeader)) > 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
peer, ok := peerAddress(r.RemoteAddr)
|
||||
if !ok || !isTrusted(trusted, peer) {
|
||||
// Своя строка, и уровень предупреждающий. Узнавание здесь
|
||||
// молчит предупреждением: заголовка нет, и оно считает случай
|
||||
// штатным. Без этой строки самый частый локальный отказ —
|
||||
// браузер пришёл с `::1`, а перечень называет `127.0.0.1` — не
|
||||
// отличим от поломки узнавания: человек видит отказ на всём
|
||||
// приложении и ни одного следа о том, почему подстановки не
|
||||
// было.
|
||||
//
|
||||
// Подставляемых значений в строке нет: логин — ключ к чужому
|
||||
// архиву, и запрет печатать его действует на подставленное
|
||||
// значение наравне с пришедшим.
|
||||
logger.Warn("Identity headers are not substituted for an untrusted peer",
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Тройка берётся из своего единственного дома, а не перечисляется
|
||||
// здесь заново.
|
||||
for _, name := range IdentityHeaderNames() {
|
||||
if value, named := headers[name]; named {
|
||||
r.Header.Set(name, value)
|
||||
} else {
|
||||
r.Header.Del(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Уровень `INFO`, и боевой журнал он не топит по построению: строка
|
||||
// пишется только тогда, когда подстановка действительно работает, а
|
||||
// в бою она выключена умолчанием предохранителя. Появляется строка
|
||||
// ровно в одном месте — в локальном прогоне, где стоит на каждом
|
||||
// запросе и где она и нужна: ею «узнан подстановкой» отличается от
|
||||
// «узнан прокси». На отладочном уровне её не видел бы никто:
|
||||
// настройки под уровень журнала у сервиса нет, и он зашит `INFO`.
|
||||
//
|
||||
// Подставленных значений в строке нет по той же причине, по какой их
|
||||
// нет в строке отказа выше.
|
||||
logger.Info("Identity headers substituted from configuration",
|
||||
"http.peer_addr", r.RemoteAddr,
|
||||
"capability", "access", "transport", "http")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SubstitutedHeaderNames называет заголовки, которые слой подставит, в порядке
|
||||
// объявления тройки.
|
||||
//
|
||||
// Нужен строке журнала при старте: перебор карты дал бы порядок, меняющийся от
|
||||
// запуска к запуску, а перечисление имён по месту завело бы третий список.
|
||||
func SubstitutedHeaderNames(headers map[string]string) []string {
|
||||
names := make([]string, 0, len(headers))
|
||||
for _, name := range IdentityHeaderNames() {
|
||||
if _, named := headers[name]; named {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Проверки этого файла судят отладочный запуск: чем сервис называет пришедшего,
|
||||
// когда обратного прокси рядом нет, и чего подстановке при этом не позволено.
|
||||
|
||||
// substituted — имитация заголовков, которой пользуются проверки. Значения
|
||||
// приметные: по ним же судится запрет на печать подставленного в журнал.
|
||||
var substituted = map[string]string{
|
||||
LoginHeader: "local-dev-login",
|
||||
EmailHeader: "local-dev@example.com",
|
||||
}
|
||||
|
||||
// substitutingLayer собирает слой с журналом боевого уровня и обработчиком,
|
||||
// запоминающим дошедший запрос.
|
||||
//
|
||||
// Уровень именно боевой — умолчание обработчика, то есть `INFO`: строки слоя
|
||||
// судятся такими, какими их увидит настоящий прогон, а поднятый ради проверки
|
||||
// `DEBUG` показал бы строку, которой в жизни не видит никто.
|
||||
func substitutingLayer(t *testing.T, headers map[string]string) (http.Handler, *journalBuffer, *http.Request) {
|
||||
t.Helper()
|
||||
|
||||
journal := &journalBuffer{}
|
||||
logger := slog.New(slog.NewTextHandler(journal, nil))
|
||||
|
||||
var seen http.Request
|
||||
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
seen = *r.Clone(r.Context())
|
||||
})
|
||||
|
||||
return SubstituteIdentityHeaders(headers, testTrustedNetworks(t), logger)(next), journal, &seen
|
||||
}
|
||||
|
||||
// serveSubstituted гоняет запрос через слой и отдаёт то, что дошло до узнавания.
|
||||
func serveSubstituted(
|
||||
t *testing.T, headers map[string]string, build func(*http.Request),
|
||||
) (*http.Request, string) {
|
||||
t.Helper()
|
||||
|
||||
layer, journal, seen := substitutingLayer(t, headers)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.RemoteAddr = trustedPeer
|
||||
build(req)
|
||||
|
||||
layer.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
return seen, journal.String()
|
||||
}
|
||||
|
||||
// Запрос без единого заголовка входа получает имя из настроек, и получает
|
||||
// именно то, что в них названо.
|
||||
func TestSubstitutionNamesRequestWithoutHeaders(t *testing.T) {
|
||||
seen, journal := serveSubstituted(t, substituted, func(*http.Request) {})
|
||||
|
||||
assert.Equal(t, []string{"local-dev-login"}, seen.Header.Values(LoginHeader))
|
||||
assert.Equal(t, "local-dev@example.com", seen.Header.Get(EmailHeader))
|
||||
|
||||
// Строка о подстановке идёт на `INFO` и видна журналу боевой настройки: ею
|
||||
// «узнан подстановкой» отличается от «узнан прокси».
|
||||
assert.Contains(t, journal, "level=INFO")
|
||||
assert.Contains(t, journal, "Identity headers substituted")
|
||||
for _, value := range substituted {
|
||||
assert.NotContains(t, journal, value,
|
||||
"подставленное значение уехало в журнал: логин — ключ к чужому архиву")
|
||||
}
|
||||
}
|
||||
|
||||
// Пришедший заголовок не подменяется: решает **наличие**, а не значение.
|
||||
func TestSubstitutionKeepsIncomingLoginHeader(t *testing.T) {
|
||||
seen, _ := serveSubstituted(t, substituted, func(r *http.Request) {
|
||||
r.Header.Set(LoginHeader, "came-from-proxy")
|
||||
})
|
||||
|
||||
assert.Equal(t, []string{"came-from-proxy"}, seen.Header.Values(LoginHeader))
|
||||
}
|
||||
|
||||
// Два значения остаются двумя: узнавание отвергает такой запрос, и слой,
|
||||
// дописавший третье или заменивший оба, превратил бы законный отказ в проход.
|
||||
func TestSubstitutionLeavesDoubledLoginHeaderAlone(t *testing.T) {
|
||||
seen, _ := serveSubstituted(t, substituted, func(r *http.Request) {
|
||||
r.Header.Add(LoginHeader, "first")
|
||||
r.Header.Add(LoginHeader, "second")
|
||||
})
|
||||
|
||||
assert.Equal(t, []string{"first", "second"}, seen.Header.Values(LoginHeader))
|
||||
}
|
||||
|
||||
// Недоверенный адрес имени не получает, и отказ этот оставляет **свою** строку:
|
||||
// узнавание здесь молчит предупреждением, потому что заголовка нет вовсе.
|
||||
func TestSubstitutionRefusesUntrustedPeer(t *testing.T) {
|
||||
layer, journal, seen := substitutingLayer(t, substituted)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.RemoteAddr = untrustedPeer
|
||||
layer.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
assert.Empty(t, seen.Header.Values(LoginHeader),
|
||||
"запрос с недоверенного адреса получил имя из настроек")
|
||||
|
||||
text := journal.String()
|
||||
assert.Contains(t, text, "level=WARN")
|
||||
assert.Contains(t, text, "203.0.113.9", "адреса пира в строке нет: поломку не отличить")
|
||||
for _, value := range substituted {
|
||||
assert.NotContains(t, text, value, "подставляемое значение уехало в журнал")
|
||||
}
|
||||
}
|
||||
|
||||
// Слой владеет тройкой целиком: чужая почта при своём логине собрала бы личность
|
||||
// из двух источников.
|
||||
func TestSubstitutionOwnsWholeHeaderSet(t *testing.T) {
|
||||
seen, _ := serveSubstituted(t, map[string]string{LoginHeader: "local-dev-login"},
|
||||
func(r *http.Request) {
|
||||
r.Header.Set(EmailHeader, "someone-else@example.com")
|
||||
r.Header.Set(NameHeader, "Чужое имя")
|
||||
})
|
||||
|
||||
assert.Equal(t, "local-dev-login", seen.Header.Get(LoginHeader))
|
||||
assert.Empty(t, seen.Header.Get(EmailHeader),
|
||||
"чужой адрес почты дошёл до узнавания вместе со своим логином")
|
||||
assert.Empty(t, seen.Header.Get(NameHeader),
|
||||
"чужое имя дошло до узнавания вместе со своим логином")
|
||||
}
|
||||
|
||||
// setupSubstitutingEnv собирает поверхность сервиса с включённым отладочным
|
||||
// входом — тем же способом, каким её собирает точка входа.
|
||||
func setupSubstitutingEnv(t *testing.T, headers map[string]string) *testEnv {
|
||||
t.Helper()
|
||||
|
||||
return setupEnv(t, envOptions{
|
||||
metaviewer: readableMetaViewer(),
|
||||
dist: builtDist(),
|
||||
built: true,
|
||||
substitution: headers,
|
||||
})
|
||||
}
|
||||
|
||||
// Сквозной сценарий: запрос без заголовков узнаётся, и учётная запись заводится
|
||||
// под именем из настроек.
|
||||
func TestSubstitutedRequestIsIdentifiedEndToEnd(t *testing.T) {
|
||||
env := setupSubstitutingEnv(t, substituted)
|
||||
|
||||
before := countAccounts(t, env)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.RemoteAddr = trustedPeer
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "запрос без заголовков остался неузнанным")
|
||||
assert.Equal(t, before+1, countAccounts(t, env),
|
||||
"учётная запись подставленного логина не завелась")
|
||||
}
|
||||
|
||||
// Подставленное значение не встречается ни в одной журнальной записи: заведение
|
||||
// учётной записи идёт на INFO, и логин в эту строку попасть не вправе.
|
||||
func TestSubstitutedValueNeverReachesJournal(t *testing.T) {
|
||||
env := setupSubstitutingEnv(t, substituted)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.RemoteAddr = trustedPeer
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
require.NotEmpty(t, journal, "журнал пуст — проверке не на чем сработать")
|
||||
for _, value := range substituted {
|
||||
assert.NotContains(t, journal, value, "подставленное значение уехало в журнал")
|
||||
}
|
||||
}
|
||||
|
||||
// Наблюдение подстановки не видит: область слоя равна области узнавания и
|
||||
// выводится из неё, а не перечисляется вторым списком.
|
||||
func TestObservationAddressesIgnoreSubstitution(t *testing.T) {
|
||||
plain := setupTestEnv(t, readableMetaViewer())
|
||||
debug := setupSubstitutingEnv(t, substituted)
|
||||
|
||||
before := countAccounts(t, debug)
|
||||
|
||||
for _, path := range []string{HealthPath, MetricsPath} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
request := func(env *testEnv) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.RemoteAddr = trustedPeer
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
plainResponse := request(plain)
|
||||
debugResponse := request(debug)
|
||||
|
||||
assert.Equal(t, plainResponse.Code, debugResponse.Code)
|
||||
assert.Equal(t, plainResponse.Body.String(), debugResponse.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
assert.Equal(t, before, countAccounts(t, debug),
|
||||
"проба здоровья завела учётную запись подставленного логина")
|
||||
}
|
||||
|
||||
// Ограничитель частоты работает и в отладочном запуске: предохранитель — это
|
||||
// закрытый перечень следствий, а не режим.
|
||||
func TestRateLimitStillCutsInDebugRun(t *testing.T) {
|
||||
env := setupSubstitutingEnv(t, substituted)
|
||||
|
||||
refused := 0
|
||||
for range appRateMaxRequests + 10 {
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.RemoteAddr = trustedPeer
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
if w.Code == http.StatusTooManyRequests {
|
||||
refused++
|
||||
}
|
||||
}
|
||||
|
||||
assert.Positive(t, refused, "ограничитель частоты в отладочном запуске не сработал")
|
||||
}
|
||||
|
||||
// Пустая имитация заголовков не трогает: слой отдаёт цепочку нетронутой, и до
|
||||
// узнавания доходит ровно тот запрос, который пришёл.
|
||||
//
|
||||
// Судится дошедшее до узнавания, а не строка журнала: запрос идёт с доверенного
|
||||
// адреса и без `Remote-User` — ровно тот случай, в котором слой, встань он в
|
||||
// цепочку, срезал бы чужие `Remote-Email` и `Remote-Name`.
|
||||
func TestEmptySectionLeavesHeadersUntouched(t *testing.T) {
|
||||
seen, _ := serveSubstituted(t, nil, func(r *http.Request) {
|
||||
r.Header.Set(EmailHeader, "came-from-proxy@example.com")
|
||||
r.Header.Set(NameHeader, "Пришедшее имя")
|
||||
})
|
||||
|
||||
assert.Equal(t, "came-from-proxy@example.com", seen.Header.Get(EmailHeader),
|
||||
"пустая имитация срезала пришедший адрес почты")
|
||||
assert.Equal(t, "Пришедшее имя", seen.Header.Get(NameHeader),
|
||||
"пустая имитация срезала пришедшее имя")
|
||||
assert.Empty(t, seen.Header.Values(LoginHeader),
|
||||
"пустая имитация назвала пришедшего")
|
||||
}
|
||||
|
||||
// Запрос без заголовка с недоверенного адреса оставляет предупреждение с
|
||||
// адресом пира: без него самый частый локальный отказ — браузер пришёл с `::1`,
|
||||
// а перечень называет `127.0.0.1` — не отличим от поломки узнавания.
|
||||
func TestUntrustedPeerWithoutHeaderIsLoggedInDebugRun(t *testing.T) {
|
||||
env := setupSubstitutingEnv(t, substituted)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/app/me", nil)
|
||||
req.RemoteAddr = untrustedPeer
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
|
||||
journal := env.journal.String()
|
||||
assert.Contains(t, journal, "Identity headers are not substituted")
|
||||
assert.Contains(t, journal, "203.0.113.9")
|
||||
for _, value := range substituted {
|
||||
assert.NotContains(t, journal, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Конфиг без новых ключей даёт ту же цепочку слоёв и те же ответы: отсутствие
|
||||
// предохранителя не меняет ни одного байта поведения.
|
||||
func TestChainWithoutSubstitutionAnswersAsBefore(t *testing.T) {
|
||||
plain := setupTestEnv(t, readableMetaViewer())
|
||||
debug := setupSubstitutingEnv(t, nil)
|
||||
|
||||
paths := []string{"/app/me", "/app/audiorecords", HealthPath, "/nothing-was-ever-here"}
|
||||
for _, path := range paths {
|
||||
t.Run(strings.TrimPrefix(path, "/"), func(t *testing.T) {
|
||||
request := func(env *testEnv) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.RemoteAddr = trustedPeer
|
||||
w := httptest.NewRecorder()
|
||||
env.mux.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
plainResponse := request(plain)
|
||||
debugResponse := request(debug)
|
||||
|
||||
assert.Equal(t, plainResponse.Code, debugResponse.Code)
|
||||
assert.Equal(t, plainResponse.Body.String(), debugResponse.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -254,6 +254,10 @@ type envOptions struct {
|
||||
metaviewer contract.AudioMetaViewer
|
||||
dist fs.FS
|
||||
built bool
|
||||
// substitution — имитация заголовков входа отладочного запуска. Пустая
|
||||
// значит «предохранитель выключен»: слой подстановки в цепочку не встаёт,
|
||||
// и поверхность собирается ровно та же, что в бою.
|
||||
substitution map[string]string
|
||||
}
|
||||
|
||||
func setupTestEnv(t *testing.T, metaviewer contract.AudioMetaViewer) *testEnv {
|
||||
@@ -306,7 +310,7 @@ func setupEnv(t *testing.T, opts envOptions) *testEnv {
|
||||
handler := NewAppHandler(recordRepo, textRepo, structureRepo, fileRepo, trsService, logger)
|
||||
|
||||
mounts := ServiceMounts(
|
||||
AppChain(handler.Routes(), users, testTrustedNetworks(t), logger),
|
||||
AppChain(handler.Routes(), users, testTrustedNetworks(t), opts.substitution, logger),
|
||||
http.NotFoundHandler(),
|
||||
)
|
||||
webapp := NewWebappHandler(opts.dist, opts.built, logger)
|
||||
|
||||
Reference in New Issue
Block a user