локальный вход задаётся конфигом: заголовки подставляет сам сервис

- в конфиг добавлены секция [auth.test_headers] и предохранитель [server] debug:
  заголовки входа подставляет слой транспорта, второго процесса локальный запуск
  больше не требует
- подкоманда devtools proxy удалена целиком: всё, ради чего её поднимали, делает
  сам сервис
- адресного предохранителя нет по решению владельца — цена названа в ADR и в
  модели угроз
This commit is contained in:
av
2026-08-23 13:12:47 +03:00
parent 75c6f0168a
commit 52fe31319a
35 changed files with 3548 additions and 144 deletions
+1 -1
View File
@@ -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)
+9
View File
@@ -23,6 +23,15 @@ const (
EmailHeader = "Remote-Email"
)
// IdentityHeaderNames отдаёт имена заголовков входа целиком, в одном порядке.
//
// Перечисление тройки живёт здесь и только здесь. Всякий, кому нужен её состав —
// слой подстановки, проверка старта, строка журнала, — берёт его отсюда: второй
// список разошёлся бы с первым молча, а имена нормативны.
func IdentityHeaderNames() []string {
return []string{LoginHeader, NameHeader, EmailHeader}
}
// ForwardedForHeader — заголовок, которым прокси называет адрес спрашивающего.
// Читает его только ограничитель частоты: барьером узнавания он не служит и
// служить не может — кто пришёл, решает адрес самого соединения.
+14 -3
View File
@@ -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
}
+1 -1
View File
@@ -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)
+138
View File
@@ -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
}
+297
View File
@@ -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())
})
}
}
+5 -1
View File
@@ -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)