вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
+106
-53
@@ -3,14 +3,21 @@
|
||||
## Периметр
|
||||
|
||||
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
|
||||
через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Без
|
||||
входа открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
|
||||
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
|
||||
`trusted-header-login`, называет она его **заголовком, который ставит обратный
|
||||
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
|
||||
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
|
||||
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
|
||||
**каждый** запрос, и отзыв доступа действует со следующего.
|
||||
|
||||
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||||
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
|
||||
принадлежащий ни одному корню сервиса. Иначе не вошедший не дошёл бы до входа
|
||||
вовсе: закрытая сессией разметка отдала бы ему отказ вместо экрана. Данных
|
||||
открытость не касается — всякий адрес под корнем приложения сессии по-прежнему
|
||||
требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
|
||||
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
|
||||
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
|
||||
открытость не касается — всякий адрес под корнем приложения узнанного
|
||||
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
|
||||
|
||||
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
|
||||
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
|
||||
@@ -58,13 +65,49 @@ Telegram — связи чата с учётной записью сервис
|
||||
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
|
||||
прокси живёт в `pet-project-server`, вне этого репозитория.
|
||||
|
||||
**Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login`
|
||||
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
|
||||
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
|
||||
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
|
||||
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
|
||||
`error_text`; база в этом перечне не значится, и запрет не нарушен. Но место
|
||||
новое: **чтение файла базы теперь равносильно чтению секрета клиента**.
|
||||
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||||
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||||
секрет в настройки коллекции пользователей, и чтение файла базы становилось
|
||||
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
|
||||
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
|
||||
|
||||
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
|
||||
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
|
||||
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
|
||||
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
|
||||
и аноним войдёт под любым именем. Половину этой беды сервис закрывает сам:
|
||||
запрос с двумя значениями `Remote-User` не узнаёт никого. Вторую половину
|
||||
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
|
||||
репозитория `pet-project-server`, и **требование к нему такое — заголовки
|
||||
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||||
человек.
|
||||
|
||||
**То же требование распространяется на `X-Forwarded-For`, и по другой причине.**
|
||||
С 2026-08-22 сервис называет этот заголовок хранилищу источником адреса
|
||||
спрашивающего — иначе счётчик ограничителя частоты ключуется адресом пира, а
|
||||
пир теперь всегда один, и бюджет становится общим на весь сервис. Прокси,
|
||||
дописывающий `X-Forwarded-For` к присланному вместо замены, отдаёт ключ счётчика
|
||||
самому спрашивающему: тот меняет значение и обходит ограничитель. Барьером
|
||||
узнавания этот заголовок при этом не служит — кто пришёл, решает адрес самого
|
||||
соединения.
|
||||
|
||||
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||||
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||||
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
|
||||
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
|
||||
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
|
||||
именно поэтому.
|
||||
|
||||
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
|
||||
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
|
||||
после ухода прежнего владельца, и тогда новый человек при первом же обращении
|
||||
попадает в **существующую** запись и получает весь её архив — самое
|
||||
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
|
||||
может: неизменяемого признака заголовок не приносит. Не допускать
|
||||
переиспользования — работа провайдера, и это принятая цена, записанная в
|
||||
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
|
||||
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
|
||||
нечем ни слить, ни убрать.
|
||||
|
||||
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||||
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
|
||||
@@ -79,10 +122,11 @@ Telegram — связи чата с учётной записью сервис
|
||||
|
||||
| Вход | Канал | Кто может слать |
|
||||
| --- | --- | --- |
|
||||
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой записи |
|
||||
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой вошедший; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой вошедший; значение вне закрытого перечня даёт `400` |
|
||||
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения |
|
||||
| Аудиофайл и его имя | `POST /app/audiorecords`, multipart-поле `audio` | Любой узнанный; неузнанному — `401` до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
|
||||
| Идентификатор записи | `GET /app/audiorecords/{id}` и `/text` | Любой узнанный; неузнанному — `401`, одинаковый для заведённой и незаведённой записи |
|
||||
| Ключ страницы, размер страницы, состояние отбора | `GET /app/audiorecords`, параметры запроса | Любой узнанный; нечитаемый ключ и негодный размер дают `400`, а не молчаливую первую страницу |
|
||||
| Вид текста | `GET /app/audiorecords/{id}/text`, параметр `view` | Любой узнанный; значение вне закрытого перечня даёт `400` |
|
||||
| Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель |
|
||||
| Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи |
|
||||
|
||||
@@ -92,7 +136,6 @@ Telegram — связи чата с учётной записью сервис
|
||||
| Вход | Канал | Кто может слать | Чья задача |
|
||||
| --- | --- | --- | --- |
|
||||
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
|
||||
| Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` |
|
||||
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
|
||||
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
|
||||
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
|
||||
@@ -179,39 +222,48 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
## Что разграничивает доступ
|
||||
|
||||
- **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется
|
||||
кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом
|
||||
([database.md](database.md), «Настройки с числовым значением»).
|
||||
Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое
|
||||
бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера
|
||||
доходит до сервиса, — не значил бы ничего.
|
||||
Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и
|
||||
та же проверка, но она названа здесь отдельно, потому что это второй способ
|
||||
предъявить ту же сессию.
|
||||
- **Файл записи** — короткий токен файла, который узнанный отправитель берёт у
|
||||
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
|
||||
коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть
|
||||
правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок
|
||||
здесь «сессия → токен файла → ссылка».
|
||||
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
|
||||
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
|
||||
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||||
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||||
отзыв доступа у Authelia действует со следующего обращения.
|
||||
Предъявленный собственный токен хранилища побеждает заголовок: им работает
|
||||
владелец панели, и подмена его учётной записью пользователя отобрала бы у него
|
||||
панель. Протухший и негодный токен предъявленными не считаются.
|
||||
**Область узнавания сужена** до корня приложения и адреса выдачи файлового
|
||||
токена: собственная поверхность хранилища под неё не подпадает, иначе узнанный
|
||||
переписал бы себе ключ учётной записи на чужое имя.
|
||||
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||||
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||||
снаружи нельзя, все пять правил доступа коллекции пользователей закрыты шагом
|
||||
схемы `202608220001`.
|
||||
- **Файл записи** — короткий токен файла, который берёт узнанный. Поле файла
|
||||
помечено защищённым, правило просмотра коллекции пускает только владельца
|
||||
файла, и ссылка `/api/files/...` перестала быть правом пройти по ней. Одного
|
||||
заголовка мало: порядок здесь «узнавание → токен файла → ссылка». **Это
|
||||
единственное значение, переживающее запрос**, и на его срок отзыв доступа до
|
||||
файловой ссылки не доходит.
|
||||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||||
2026-08-12.
|
||||
- **Заведение учётной записи** — только входом у провайдера. Собственное
|
||||
создание записи, вход по паролю, одноразовый код и восстановление доступа
|
||||
выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и
|
||||
без этого закрытия вход обходился бы двумя запросами.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии:
|
||||
её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного
|
||||
- **Собственный вход хранилища закрыт целиком.** Создание записи, вход по
|
||||
паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление
|
||||
доступа и продление — ни один не даёт доступа и не меняет учётной записи:
|
||||
хранилище заводит коллекцию пользователей открытой, и без этого закрытия
|
||||
узнавание обходилось бы двумя запросами.
|
||||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||||
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||||
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||||
эти адреса не несут.
|
||||
- **Приложение** — его разметка и ресурсы открыты без сессии, и ограничителя
|
||||
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
|
||||
частоты на них нет: правило заведено под корень приложения, а раздача стоит
|
||||
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
|
||||
для всех и собраны до всякого запроса. По ответу нельзя узнать, вошёл ли
|
||||
кто-то, — вошедшему и не вошедшему отдаётся одно и то же.
|
||||
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
|
||||
кто-то, — узнанному и неузнанному отдаётся одно и то же.
|
||||
|
||||
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||||
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||||
@@ -222,9 +274,9 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
| Механизм | Что даёт | Чья задача |
|
||||
| --- | --- | --- |
|
||||
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` |
|
||||
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
|
||||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
|
||||
| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` |
|
||||
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
|
||||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||||
|
||||
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
||||
@@ -252,8 +304,8 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
наравне с записью — норму держит спека `storage`.
|
||||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||||
Утечка оплачивается деньгами и доступом к бакету.
|
||||
3. **Секрет клиента OIDC** — вместе с адресами провайдера открывает вход в
|
||||
приложение от чужого имени.
|
||||
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
|
||||
из базы вместе с собственным входом.
|
||||
|
||||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||||
@@ -335,13 +387,14 @@ Storage, оттуда его читает SpeechKit. Третий путь —
|
||||
|
||||
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
|
||||
Ansible — не наша граница.
|
||||
- **Машина разработчика и то, что он на ней поднимает.** С 2026-08-15 в
|
||||
репозитории лежит `cmd/oidcstub` — подставной провайдер OIDC, который выдаёт
|
||||
сессию всякому спросившему и не сверяет ни секрета клиента, ни проверочного
|
||||
кода PKCE. Двух вещей это не отменяет, и обе проверяемы: в образ он не едет
|
||||
(ступень собирает `./cmd/transcriber` поимённо), а слушает петлевой адрес.
|
||||
Периметра выкладки заглушка поэтому не касается; кто поднял её у себя в чужой
|
||||
сети, отвечает за это сам.
|
||||
- **Машина разработчика и то, что он на ней поднимает.** В репозитории лежит
|
||||
`cmd/devtools` — оснастка разработчика; её подкоманда `proxy` встаёт на место
|
||||
контура: ставит заголовок `Remote-User` и переправляет запрос сервису, не
|
||||
проверяя ничего. С 2026-08-15 по 2026-08-22 ту же роль играл `cmd/oidcstub`,
|
||||
подставной провайдер OIDC. Двух вещей это не отменяет, и обе проверяемы: в
|
||||
образ оснастка не едет (ступень собирает `./cmd/transcriber` поимённо), а
|
||||
слушает петлевой адрес. Периметра выкладки она поэтому не касается; кто поднял
|
||||
её у себя в чужой сети, отвечает за это сам.
|
||||
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
||||
доверяем полностью.
|
||||
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
||||
|
||||
Reference in New Issue
Block a user