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

- в конфиг добавлены секция [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
+314
View File
@@ -72,6 +72,22 @@
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` доверенным источником становятся настройки сервиса: сервис
называет пришедшего сам, никого не спросив. Это единственное место, где имя
берётся не от провайдера. Условия, при которых источник этот законен, стоят
требованиями «Отладочный запуск называет пришедшего настройками», «Настройка,
открывающая вход всем, роняет старт», «Отладочный запуск виден в журнале» и
«Предохранитель отладки включает только подстановку заголовков»; снимать их
поодиночке нельзя: барьер держится всеми разом.
Держится изъятие **умолчанием, а не машиной**: предохранитель по умолчанию
выключен, а заполненная имитация без него роняет старт. Боевой перечень
доверенных адресов включению предохранителя не мешает — сервис, поднятый в бою с
включённым предохранителем и заполненной имитацией, назовёт своим именем всякого,
чей запрос пришёл через обратный прокси, то есть всякого, кто пришёл обычным
путём.
#### Scenario: Названный провайдером получает доступ
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
@@ -86,6 +102,12 @@
- **WHEN** приходит следующий его запрос
- **THEN** прокси заголовка не ставит, и запрос получает отказ
#### Scenario: Без отладочного запуска имя приходит только от провайдера
- **GIVEN** предохранитель отладки выключен
- **WHEN** запрос приходит с доверенного адреса без заголовка
- **THEN** сервис никого не узнаёт и своего имени пришедшему не назначает
### Requirement: Проба здоровья и метрики остаются открытыми
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
@@ -251,6 +273,17 @@ MUST отвечать отказом `401`, когда пришедший не
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
не остаётся.
**Изъятие одно — отладочный запуск.** При включённом предохранителе
`[server] debug` заголовок входа ставит не прокси, а сам сервис значением из
секции `[auth] test_headers`; узнавание при этом остаётся тем же и подставленного
заголовка от пришедшего не отличает. Условия, при которых источник этот законен,
и проверки старта, которыми он держится, стоят требованиями «Отладочный запуск
называет пришедшего настройками», «Настройка, открывающая вход всем, роняет
старт», «Отладочный запуск виден в журнале» и «Предохранитель отладки включает
только подстановку заголовков». Держится изъятие умолчанием предохранителя
«выключено» и отказом старта при заполненной имитации без него; боевой перечень
доверенных адресов включению предохранителя не мешает.
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
@@ -554,3 +587,284 @@ MUST не выдавать вовсе — ни куки, ни токена се
- **WHEN** сервис поднимается с заполненным перечнем
- **THEN** журнал подъёма называет доверенные адреса
### Requirement: Отладочный запуск называет пришедшего настройками
Сервис SHALL подставлять запросу заголовки входа значениями из секции
`[auth] test_headers`, когда включён предохранитель `[server] debug`, и MUST
делать это так, чтобы узнавание не отличало подставленный заголовок от
поставленного обратным прокси. Ветка кода, которой узнаётся пришедший, обязана
быть той же, что работает в бою: отладке подлежит боевой путь, а не его
отладочный двойник.
Подстановка MUST происходить только при **отсутствии** заголовка `Remote-User` в
запросе. Заголовок, пришедший в любом числе значений, MUST оставаться нетронутым:
узнавание отвергает запрос с более чем одним значением, и подстановка, дописавшая
второе, превратила бы законный отказ в проход.
Подстановка MUST происходить только тогда, когда адрес соединения попадает в
перечень `[auth] trusted_proxies`, и адрес этот MUST судиться **той же
функцией**, какой судит его узнавание: и разбор адреса пира, и сверка с
перечнем берутся из одного дома, а не пишутся вторым списком. Второй перечень
разошёлся бы с первым молча — так же, как разошлись бы два списка имён
заголовков. Второго барьера у подстановки нет и не заводится: круг тех, кто
вправе назвать пришедшего, уже очерчен этим перечнем.
Подстановка MUST действовать только при **непустой** секции имитации. Пустая
секция значит «подставлять нечего»: цепочка слоёв при ней та же, что при
выключенном предохранителе, и ни одного заголовка запроса слой не трогает.
Иначе включённый предохранитель при пустой секции срезал бы `Remote-Email` у
запроса, пришедшего без `Remote-User`, — поведение, которого в бою нет.
Подставляя, сервис MUST распоряжаться всей тройкой заголовков `Remote-*`
целиком: названные секцией ставятся её значением, не названные удаляются.
Запрос без `Remote-User`, но с `Remote-Email` иначе собрал бы личность из двух
источников — логин свой, почту чужую.
Подстановка MUST действовать только под корнем приложения — там же, где действует
узнавание. Проба здоровья, метрики и раздача приложения её не видят.
#### Scenario: Запрос без заголовка входа получает имя из настроек
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса без единого заголовка `Remote-*`
- **THEN** сервис узнаёт пришедшего под именем из секции
- **AND** учётная запись заводится, если её не было
#### Scenario: Пришедший заголовок не подменяется
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с доверенного адреса с заголовком `Remote-User`
- **THEN** узнавание получает значение из запроса, а не из настроек
#### Scenario: Два значения заголовка остаются отказом
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит с двумя значениями заголовка `Remote-User`
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Недоверенный адрес имени не получает
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос без заголовка приходит с адреса вне перечня доверенных
- **THEN** сервис не подставляет ничего, и запрос остаётся неузнанным
#### Scenario: Чужой заголовок почты не смешивается со своим логином
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и не
называет `Remote-Email`
- **WHEN** запрос приходит без `Remote-User`, но с заголовком `Remote-Email`
- **THEN** узнавание получает логин из настроек и не получает адреса почты вовсе
#### Scenario: Пустая секция имитации заголовков не трогает
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без `Remote-User`, но с
заголовком `Remote-Email`
- **THEN** слой заголовков не трогает, и `Remote-Email` доходит до узнавания
нетронутым
#### Scenario: Выключенный предохранитель имён не раздаёт
- **GIVEN** предохранитель выключен, секция имитации пуста
- **WHEN** запрос приходит с доверенного адреса без заголовка `Remote-User`
- **THEN** запрос остаётся неузнанным
#### Scenario: Наблюдение подстановки не видит
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
- **WHEN** запрос приходит на `GET /health` без заголовка
- **THEN** ответ тот же, что и при выключенном предохранителе
### Requirement: Настройка, открывающая вход всем, роняет старт
Сервис SHALL отказываться подниматься на настройках, при которых отладочный вход
становится открытым входом, и MUST называть в отказе имя ключа. Проверка идёт на
старте, до приёма трафика: настройка, отданная на честность выкладки, проверяется
только тем, что чужой архив уже уехал не тому.
Умолчания названы нормой, а не образцом конфига. Отсутствие ключа `[server]
debug` MUST читаться как выключенный предохранитель, а отсутствие секции
`[auth] test_headers` — как пустая секция: конфиг сегодняшнего дня, не тронутый
ни на байт, обязан вести себя ровно как вёл. Ошибка разбора значения MUST
кончаться отказом старта, а не прочтением «включено».
Секция имитации MUST считаться **непустой**, как только в ней назван хотя бы
один ключ, каким бы ни было его значение. `Remote-User = ""` — заполненная
секция, а не пустая: человек, написавший ключ, имитацию завёл, и пустое значение
у него — вторая поломка, а не отсутствие первой.
Отказом MUST быть каждый из четырёх случаев.
Первый — секция имитации заполнена при выключенном предохранителе. Состояние это
не читается никак: либо человек забыл включить предохранитель и будет искать
поломку везде, кроме одного ключа, либо забыл убрать имитацию из боевого файла — и
тогда до открытого архива остаётся одно слово. Молчаливое игнорирование и
предупреждение в журнале оба оставляют вторую поломку жить.
Второй — секция имитации называет ключ, не совпадающий ни с одним заголовком
входа, который сервис читает. Перечень принимаемых имён MUST порождаться теми же
именами заголовков, которыми пользуется узнавание, а не перечисляться вторым
списком; сравнение MUST идти по каноническому виду имени, потому что в HTTP имя
нечувствительно к регистру, а ключ конфига чувствителен. Опечатка в имени иначе
кончается сервисом, который никого не узнаёт, без единого следа.
Третий — секция имитации непуста, а годного `Remote-User` в ней нет. Ключ
логина MUST быть назван: секция, называющая один `Remote-Email`, поднимает
сервис, который подставит почту, удалит логин и не узнает никого. Значение
логина MUST проходить **тот же приём**, каким узнавание судит пришедшее
значение (`internal/entity.AcceptProviderLogin`): пустое, из одних пробельных
знаков, сверх предела длины и с управляющими знаками — отказ старта. Иначе
сервис поднимается, ставит заголовок, получает отказ приёма и отвечает
неузнанным на всё, оставляя за собой одну отладочную строку. Оба состояния — то
самое «не читается никак», ради которого заведён первый случай.
Четвёртый — два ключа секции дают одно каноническое имя заголовка. `Remote-User`
и `remote-user` для TOML — два ключа, для HTTP — одно имя: сервис подставил бы
одно значение, а второе потерял бы молча, и человек искал бы поломку в значении,
которого сервис не читал вовсе. Отказ MUST называть секцию и причину, а значений
MUST не называть.
Включённый предохранитель при пустой секции имитации отказом MUST не быть: сам по
себе он ничего не включает.
#### Scenario: Имитация без предохранителя не поднимается
- **GIVEN** секция имитации заполнена, предохранитель выключен
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Неизвестное имя заголовка не поднимается
- **GIVEN** предохранитель включён, секция имитации называет ключ, которого нет
среди читаемых заголовков входа
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет принимаемые имена
#### Scenario: Имитация без ключа логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет только
`Remote-Email`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя недостающего ключа логина
#### Scenario: Два ключа одного заголовка не поднимаются
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User` и
`remote-user`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет секцию, в которой два ключа дали одно имя
заголовка
#### Scenario: Негодное значение логина не поднимается
- **GIVEN** предохранитель включён, секция имитации называет `Remote-User`
значением в 300 знаков
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа, а не его значение
#### Scenario: Пустое значение логина считается заполненной имитацией
- **GIVEN** предохранитель выключен, секция имитации называет `Remote-User = ""`
- **WHEN** сервис запускают
- **THEN** старт отказывает и называет имя ключа предохранителя
#### Scenario: Конфиг без ключа предохранителя ведёт себя как с выключенным
- **GIVEN** конфиг не называет ни `[server] debug`, ни секции имитации
- **WHEN** сервис запускают
- **THEN** сервис поднимается, подстановки не заводит и никого сам не называет
#### Scenario: Предохранитель без имитации поднимается
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис запускают
- **THEN** сервис поднимается
### Requirement: Отладочный запуск виден в журнале
Сервис SHALL сообщать владельцу о включённой подстановке заголовков строкой
журнала при старте и MUST не печатать при этом ни одного подставляемого значения.
Сервис, называющий пришедшего сам, — это ровно то «может стать проблемой», ради
которого заведён предупреждающий уровень: строка старта идёт на `WARN`.
Строка старта MUST нести имена подставляемых заголовков и MUST не нести их
значений. Логин — ключ к чужому архиву, и запрет на печать значения, дающего
доступ, действует на подставленное значение наравне с пришедшим.
Запрос, которому заголовки подставлены, SHALL писаться на уровне `INFO`:
этой строкой «узнан подстановкой» отличается от «узнан прокси». Боевой журнал
она не топит по построению — пишется только там, где подстановка работает, а в
бою предохранитель выключен умолчанием; на отладочном уровне её не увидел бы
никто, потому что настройки под уровень журнала у сервиса нет и он зашит `INFO`.
Строка MUST нести адрес пира и не нести подставленных значений.
Запрос, которому подставить нельзя из-за недоверенного адреса, MUST оставлять
**свою** строку предупреждающим уровнем. Узнавание на этом месте предупреждения
не пишет: подстановка работает при отсутствии `Remote-User`, а отсутствие
заголовка узнавание считает случаем штатным и пишет о нём отладочную строку;
предупреждение о недоверенном адресе оно бережёт для заголовка, который
**пришёл**. Без своей строки самый частый локальный отказ — браузер пришёл
с `::1`, а перечень называет `127.0.0.1` — не отличим от поломки узнавания:
человек видит `401` на всём приложении, заполненную секцию имитации и
включённый предохранитель. Строка MUST нести адрес пира и MUST не нести
подставляемых значений.
#### Scenario: Старт с подстановкой предупреждает владельца
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** сервис поднимается
- **THEN** журнал несёт предупреждение с именами подставляемых заголовков
#### Scenario: Подставленного значения нет в журнале
- **GIVEN** предохранитель включён, секция имитации называет логин и адрес почты
- **WHEN** сервис поднимается и принимает запрос без заголовка
- **THEN** ни логин, ни адрес почты не встречаются ни в одной журнальной записи
#### Scenario: Отказ подстановки на недоверенном адресе виден в журнале
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос без заголовка `Remote-User` приходит с адреса вне перечня
доверенных
- **THEN** журнал несёт предупреждение с адресом пира
- **AND** подставляемых значений в строке нет
#### Scenario: Подстановка на запросе видна при боевом уровне журнала
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запрос с доверенного адреса получает подставленные заголовки
- **THEN** строка об этом имеет уровень `INFO` и видна журналу, настроенному
по-боевому
### Requirement: Предохранитель отладки включает только подстановку заголовков
Предохранитель `[server] debug` SHALL менять одно поведение сервиса — подстановку
заголовков входа — и MUST не менять никакого другого. Перечень следствий закрыт, и
новое следствие вешается на этот ключ только отдельным требованием спеки: ключ,
названный общим словом, иначе обрастает всем подряд, и выключить его перестаёт
означать «сервис ведёт себя как в бою».
Сам по себе включённый предохранитель не включает и подстановки: она MUST
действовать только при непустой секции имитации, и при пустой цепочка слоёв MUST
быть та же, что при выключенном предохранителе, — требование «Отладочный запуск
называет пришедшего настройками».
Включённый предохранитель MUST не менять уровень журнала, не добавлять в ответы
тексты внутренних отказов и следы стека, не выводить тела запросов и ответов
внешних сервисов, не снимать и не ослаблять ограничитель частоты, не подменять
распознаватель, не ослаблять ни одной проверки старта и не открывать неузнанному
ни одного адреса.
#### Scenario: Включённый предохранитель без имитации ничего не меняет
- **GIVEN** предохранитель включён, секция имитации пуста
- **WHEN** сервис принимает запросы
- **THEN** он ведёт себя ровно так же, как с выключенным предохранителем
#### Scenario: Ограничитель частоты работает и в отладочном запуске
- **GIVEN** предохранитель включён, секция имитации заполнена
- **WHEN** запросы идут чаще дозволенного
- **THEN** ограничитель частоты режет их так же, как при выключенном
предохранителе