- один факт — один дом: рецепт локального входа, правило чтения X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к своим домам, копии заменены ссылками - форма [auth.test_headers] выровнена по образцу конфига в семи местах; сценарии intake и archive перестали ссылаться на сессию, которой сервис не выдаёт - поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку адреса пира зовут трое, а не двое; обзор capability access знает о задаче 2026-08-23
459 lines
48 KiB
Markdown
459 lines
48 KiB
Markdown
# Модель угроз
|
||
|
||
## Периметр
|
||
|
||
**Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
|
||
обратный прокси, а приём записи, чтение её карточки и текста и файл записи
|
||
требуют, чтобы пришедшего назвала Authelia.** С 2026-08-22, задачей
|
||
`trusted-header-login`, называет она его **заголовком, который ставит обратный
|
||
прокси**: своего входа у сервиса не осталось — ни адреса к провайдеру, ни
|
||
возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (`oidc-login`
|
||
2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит
|
||
**каждый** запрос, и отзыв доступа действует со следующего.
|
||
|
||
Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей `spa-skeleton` —
|
||
**само приложение**: его разметка и её ресурсы, а вместе с ними всякий путь, не
|
||
принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси,
|
||
и человек, которого прокси не назвал, до приложения дошёл бы только мимо него —
|
||
а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных
|
||
открытость не касается — всякий адрес под корнем приложения узнанного
|
||
по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.
|
||
|
||
**Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения**,
|
||
а каталог его лежит в `.gitignore` и не судится ничем: всё, что окажется там у
|
||
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
|
||
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
|
||
браузеру сервису нечем.
|
||
|
||
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
|
||
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
|
||
страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14**
|
||
задачей `record-ownership`: и чтение записи, и файл записи сужены владельцем
|
||
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
|
||
доступа — страницы расхода для владельца сервиса.
|
||
|
||
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения
|
||
не принимает, и держит это схема хранилища. Прежде такие записи заводил вход
|
||
Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран
|
||
вместе с этим исключением.
|
||
|
||
**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи
|
||
начинает уходить на три новые стороны — языковой модели, в канал уведомлений и
|
||
на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11
|
||
сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда
|
||
уходит содержимое записи» и «Что вне модели».
|
||
|
||
**Третьего сдвига — панели администратора — больше нет, и это снятие.** Решением
|
||
от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту
|
||
появлялась панель `/_/`: доступ ко всем записям, всем файлам и всем пользователям
|
||
разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22,
|
||
задачей `storage-without-pocketbase`, встроенное хранилище убрано целиком:
|
||
панели не существует, второго периметра на порту сервиса не осталось, и правилу
|
||
прокси нечего закрывать.
|
||
|
||
**Вместе с панелью снят и дефект подменённого знака.** Маршрутизатор сравнивал
|
||
сегменты пути после раскодирования, поэтому `/%5f/` попадал в ту же группу, что и
|
||
`/_/`, а правило прокси, написанное на литерал, такой формы не видело — весь
|
||
клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи
|
||
`spa-skeleton`). Лечится он теперь тем, что за обоими адресами не стоит ничего:
|
||
оба попадают под общее правило неизвестного пути и отдают разметку приложения.
|
||
Проверено прогоном 2026-08-22: `/_/`, `/%5f/` и всякий путь под `/api/` отвечают
|
||
байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.
|
||
|
||
**Четвёртый сдвиг был — секрет клиента в базе, — и он снят.** Задача
|
||
`oidc-login` 2026-08-12 клала адреса провайдера, идентификатор клиента и его
|
||
секрет в настройки коллекции пользователей, и чтение файла базы становилось
|
||
равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не
|
||
на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.
|
||
|
||
**Вместо него — новый и главный: барьер держится на том, что прокси ставит
|
||
заголовок сам.** Сервис верит `Remote-User`, пришедшему с адреса из объявленного
|
||
перечня, а перечень этот и есть адрес прокси. Прокси, настроенный **добавлять**
|
||
заголовок вместо замены, оставит рядом со своим значением присланное анонимом —
|
||
и аноним войдёт под любым именем. Часть этой беды сервис закрывает сам:
|
||
запрос с двумя значениями `Remote-User` не узнаёт никого. **Закрыт при этом
|
||
только логин.** `Remote-Name` и `Remote-Email` берутся первым значением, то есть
|
||
присланным анонимом, и адрес почты, занятый им, закрепляется за чужой учётной
|
||
записью навсегда: колонка уникальна, а найденную запись узнавание не
|
||
переписывает. Прогон ревью 2026-08-23 построил этот путь и прогнал его;
|
||
правило решено распространить на всю тройку отдельной задачей. Остальное
|
||
проверить отсюда нечем: правило живёт в `files/caddyproxy/Caddyfile.template`
|
||
репозитория `pet-project-server`, и **требование к нему такое — заголовки
|
||
`Remote-*` прокси обязан перезаписывать, а не пропускать**. Выкладку запускает
|
||
человек.
|
||
|
||
**Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23**
|
||
задачей `config-test-headers-login`. При включённом предохранителе
|
||
`[server] debug` заголовки входа ставит не прокси, а сам сервис значениями из
|
||
секции `[auth.test_headers]`: на машине разработчика прокси нет, а браузер
|
||
заголовков не ставит. Узнавание при этом остаётся тем же и подставленного
|
||
заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует
|
||
изъятие спека [access](../openspec/specs/access/spec.md), решение о подстановке
|
||
самим сервисом —
|
||
[ADR-2026-08-23-test-headers-substituted-by-service](adr/ADR-2026-08-23-test-headers-substituted-by-service.md).
|
||
|
||
Держится оно тремя вещами, и других нет: умолчание предохранителя —
|
||
«выключено»; заполненная имитация при выключенном предохранителе роняет старт с
|
||
именем ключа; боевой конфиг рендерится шаблоном Ansible, а не копируется с
|
||
машины разработчика. Подставленный заголовок проходит тот же барьер доверенного
|
||
адреса, что и пришедший, и судит адрес та же функция — но барьером отладочному
|
||
входу это не служит: перечень доверенных адресов включению предохранителя не
|
||
мешает.
|
||
|
||
**Боевая поломка машиной не исключена, и это названо прямо.** Сервис, поднятый в
|
||
бою с включённым предохранителем и заполненной имитацией, поднимется на любом
|
||
перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл
|
||
через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного
|
||
предохранителя у изъятия нет: требование петлевого перечня рассматривалось и
|
||
снято — [ADR-2026-08-23-no-address-guard-for-debug-login](adr/ADR-2026-08-23-no-address-guard-for-debug-login.md).
|
||
|
||
**`X-Forwarded-For` сервис читает сам, и правило чтения закрывает дописывание.**
|
||
Как именно читается цепочка, нормирует спека
|
||
[archive](../openspec/specs/archive/spec.md), «Адреса приложения живут своим
|
||
пространством». Отсюда периметровое следствие: прокси, дописывающий
|
||
`X-Forwarded-For` к присланному, этим правилом покрыт, и требования
|
||
«перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от `Remote-*`.
|
||
Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого
|
||
соединения.
|
||
|
||
**Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно.**
|
||
Перечень задаёт, чьему `Remote-User` верить, и всякий, кто дотянулся до сервиса
|
||
с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать
|
||
адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой
|
||
контейнер на хосте», включая чужие. Образец конфига называет узкий пример
|
||
именно поэтому.
|
||
|
||
**Пятый сдвиг — логин у провайдера переиспользуем.** Ключ учётной записи —
|
||
`Remote-User`, то есть логин человека у Authelia. Логин можно выдать заново
|
||
после ухода прежнего владельца, и тогда новый человек при первом же обращении
|
||
попадает в **существующую** запись и получает весь её архив — самое
|
||
чувствительное, что у сервиса есть. Сервис этого не различает и различить не
|
||
может: неизменяемого признака заголовок не приносит. Не допускать
|
||
переиспользования — работа провайдера, и это принятая цена, записанная в
|
||
[access](../openspec/specs/access/spec.md). Обратная сторона той же цены:
|
||
переименование заводит **новую** запись, а прежняя остаётся с архивом, который
|
||
нечем ни слить, ни убрать.
|
||
|
||
Отсюда главное следствие, из которого читается всё остальное: **`POST
|
||
/app/audiorecords` требует входа, а размер файла ограничен потолком записи, число
|
||
же запросов ограничено только частотой**. Вошедший тратит наши деньги на
|
||
распознавание столько, сколько захочет: ограничитель частоты под корнем
|
||
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
|
||
по-прежнему нет — её заводит `per-user-size-quota`.
|
||
|
||
## Недоверенный вход
|
||
|
||
Что приходит извне и каким каналом.
|
||
|
||
| Вход | Канал | Кто может слать |
|
||
| --- | --- | --- |
|
||
| **Имя пришедшего, имя для показа и почта** | Заголовки `Remote-User`, `Remote-Name`, `Remote-Email` | Обратный прокси — и **всякий, кто дотянулся до сервиса с доверенного адреса**. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; **два значения одного заголовка** не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения. Слать тройку может ещё и сам сервис — при включённом предохранителе `[server] debug`, значением из настроек; изъятие целиком описано в «Периметре» выше |
|
||
| Аудиофайл и его имя | `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, а через него — содержимое записи |
|
||
|
||
Что добавится вместе с целевым периметром — каждый вход появляется своей
|
||
задачей, и до неё его нет:
|
||
|
||
| Вход | Канал | Кто может слать | Чья задача |
|
||
| --- | --- | --- | --- |
|
||
| Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` |
|
||
| Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` |
|
||
| Вычитанный текст | Ответ той же модели | То же | `literary-text-level` |
|
||
| Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` |
|
||
| Хеш-сумма файла | Поле запроса приёма | Отправитель — и она же решает, отдать ли прежнюю запись | `dedup-by-content-hash` |
|
||
|
||
Ответ языковой модели опаснее прочего в этом списке: он приходит текстом, идёт
|
||
в заголовок записи и оттуда на экран — то есть внешний сервис пишет то, что
|
||
увидит человек.
|
||
|
||
## Куда уходит содержимое записи
|
||
|
||
Сегодня запись покидает наш сервер двумя путями: файл уезжает в Yandex Object
|
||
Storage, оттуда его читает SpeechKit. Третий путь — ответ в Telegram — исчез
|
||
2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом
|
||
приложения.
|
||
|
||
Целевой периметр добавляет три пути, каждый — своей задачей:
|
||
|
||
| Куда | Что уходит | Чья задача |
|
||
| --- | --- | --- |
|
||
| Языковая модель за шлюзом bifrost | Текст расшифровки целиком | `llm-insights-adapter`, затем `literary-text-level` |
|
||
| Канал уведомлений (ntfy через apprise) | Готовый текст либо причина отказа | `ntfy-delivery` |
|
||
| Почтовый сервер | Готовый текст либо причина отказа, на адрес из учётной записи | `email-notification` |
|
||
|
||
Каждая из названных задач обязана оставить строку в этом разделе — там это
|
||
записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст
|
||
остаётся в приложении.
|
||
|
||
## Из чего строятся пути и ключи
|
||
|
||
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога.
|
||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||
путь не предполагался.
|
||
|
||
- **Путь на диске** выбирает сервис: `data/records/<ULID записи>/<имя>`. Обе
|
||
части задаёт он сам — подкаталог назван идентификатором записи, имя файла это
|
||
`<ULID><расширение>`, — и имя, данное отправителем, не попадает ни в одну из
|
||
них. Расширение берётся из имени отправителя через `filepath.Ext` без проверки
|
||
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
|
||
каталогов, но это единственное, что стоит между входом и именем файла. Длина
|
||
расширения при этом ограничена числом — иначе `x.` с четырьмястами знаками
|
||
роняет заведение временного файла.
|
||
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
|
||
расширением. Бакет один на все записи, префикса по пользователю нет. С
|
||
2026-08-14 копия там файлом записи не считается: она существует лишь потому,
|
||
что провайдер читает аудио по адресу, и её ключ живёт в строке попытки
|
||
распознавания.
|
||
- **Сохранённый ответ провайдера** лежит третьим файлом в том же подкаталоге
|
||
записи, под именем, которое задаёт сервис. Содержимое там — **полный текст
|
||
речи**, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса,
|
||
которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в
|
||
журнал, ни в метку метрики, ни в ответ.
|
||
- **Адрес файла** — `GET /app/audiorecords/{id}/file?copy=original|normalized`.
|
||
Право пройти по нему даёт **узнавание пришедшего и владение записью**, и
|
||
судится оно там же, где отдаётся файл. Значений на предъявителя сервис не
|
||
выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным
|
||
хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни
|
||
выданного значения. Запрет при этом остаётся: **имя файла на диске в журнал не
|
||
пишется** — строка журнала стала бы бессрочным ключом к чужой записи. В журнал
|
||
идёт расширение своим полем.
|
||
- **Идентификатор записи** — ULID, 26 знаков, выдаёт приложение. Он же
|
||
единственное, что защищает карточку записи, её текст и её файл сверх владения.
|
||
- **Чужой поверхности на порту сервиса нет.** Адреса `/api/collections/...`,
|
||
`/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/` ушли
|
||
вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими
|
||
адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит
|
||
[webapp](../openspec/specs/webapp/spec.md). Что содержимое записи закрыто
|
||
везде, где лежит, нормирует [storage](../openspec/specs/storage/spec.md).
|
||
|
||
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
|
||
|
||
- **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска
|
||
прежней записи. Ищется она **в пределах одного пользователя**: глобальный
|
||
поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
|
||
заодно сообщал бы, что запись у кого-то уже есть.
|
||
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
|
||
плоский каталог — раскладка каталога данных меняется, и это необратимо.
|
||
- **Имя отправляемого документа** (`long-text-delivery`) собирается из
|
||
идентификатора задачи: имя, данное пользователем, в него не попадает.
|
||
|
||
## Что разграничивает доступ
|
||
|
||
- **HTTP API** — заголовок `Remote-User`, пришедший с адреса из объявленного
|
||
перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого
|
||
заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения,
|
||
переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому
|
||
отзыв доступа у Authelia действует со следующего обращения.
|
||
Собственных токенов сервис не принимает вовсе: значения, предъявленного
|
||
запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое
|
||
значение било заголовок — им работал владелец панели; панели нет, и правило
|
||
приоритета осталось бы правилом без предмета.
|
||
**Область узнавания — корень приложения**, и выводится она из объявленного
|
||
адресного пространства сервиса: слои одеты на корень целиком, вторым списком
|
||
адресов область не описывается. Проба здоровья, метрики и ресурсы приложения
|
||
под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к
|
||
базе, а первый такой запрос с новым именем — записи в неё.
|
||
- **Учётная запись** — заводится первым обращением с новым логином и находится
|
||
по нему же дальше. Ключ — колонка `provider_login`, уникальная; править её
|
||
снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе:
|
||
своих экранов профиля он не заводит, а поверхности хранилища, правившей запись
|
||
библиотечным правилом, не осталось.
|
||
- **Файл записи** — узнавание пришедшего и владение записью, судимые в самом
|
||
обработчике отдачи. Отказ наступает **на обращении за файлом**: другого места,
|
||
где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос,
|
||
сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
|
||
- **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы
|
||
приложение не делает: кого пускать, определяет правило провайдера на этого
|
||
клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду
|
||
его не проверить. Клиент, настроенный слишком широко, открывает сервис
|
||
всякому, у кого есть учётная запись в общей Authelia. Решение владельца от
|
||
2026-08-12.
|
||
- **Собственного входа у сервиса нет вовсе.** Создание записи, вход по паролю,
|
||
одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и
|
||
продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать
|
||
больше нечего, и адресов этих не существует.
|
||
- **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты неузнанному:
|
||
учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и
|
||
учётной записи на них не заводит. Наружу их закрывает правило обратного
|
||
прокси — работа выкладки, и сервис на неё не полагается: содержимого записей
|
||
эти адреса не несут.
|
||
- **Приложение** — его разметка и ресурсы открыты неузнанному, и ограничителя
|
||
частоты на них нет: правило заведено под корень приложения, а раздача стоит
|
||
вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы
|
||
для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли
|
||
кто-то, — узнанному и неузнанному отдаётся одно и то же.
|
||
|
||
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла
|
||
есть владелец. Знание идентификатора задачи правом её читать больше не является
|
||
— читает её тот, кто её принёс.
|
||
|
||
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из
|
||
них уже стоит:
|
||
|
||
| Механизм | Что даёт | Чья задача |
|
||
| --- | --- | --- |
|
||
| Заголовок от Authelia через прокси | Право открыть приложение и его эндпоинты — **сделано 2026-08-22**; прежде то же давала сессия OIDC, с 2026-08-12 | `trusted-header-login` |
|
||
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` |
|
||
| Личный токен | Права своего владельца программе, которой прокси заголовка не ставит | `api-tokens` |
|
||
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` |
|
||
|
||
Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней
|
||
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
|
||
Откуда он берётся — из группы OIDC или из конфигурации — не решено
|
||
(`admin-stats-screen`).
|
||
|
||
**Панели администратора в этой таблице нет, и это снятие, а не пропуск.** До
|
||
2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и
|
||
всех пользователей мимо любого из механизмов разграничения, а пускал его свой
|
||
пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у
|
||
сервиса осталось одно — владение записью.
|
||
|
||
Владелец сервиса взамен получил одно действие и один инструмент: подкоманда
|
||
`cmd/devtools resume` возвращает остановленную запись в работу. Она ходит **в тот
|
||
же каталог данных**, то есть требует доступа к файлам сервера, а не к сети:
|
||
поверхности, открытой в интернет, у неё нет вовсе.
|
||
|
||
## Что чувствительнее чего
|
||
|
||
1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка;
|
||
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
|
||
колонкой, а шестью таблицами: сама запись (заголовок и краткое описание),
|
||
`texts` (расшифровка и вычитанный текст), `structures` (реплики со временем),
|
||
`recognitions` (попытка распознавания; **сохранённый ответ провайдера —
|
||
полный текст речи — лежит файлом в подкаталоге записи**),
|
||
`record_events` (журнал событий, содержимого не несёт) и `topics` (словарь
|
||
тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается
|
||
наравне с записью — норму держит спека `storage`.
|
||
2. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage.
|
||
Утечка оплачивается деньгами и доступом к бакету.
|
||
Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и
|
||
из базы вместе с собственным входом.
|
||
|
||
Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его
|
||
кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита.
|
||
|
||
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид
|
||
секрета, которого сегодня в проекте нет вовсе:
|
||
|
||
1. **Токены пользователей** (`api-tokens`). Токен даёт права своего владельца
|
||
целиком. Срока жизни у него нет. В базе лежит только отпечаток, полное
|
||
значение показывается один раз при выпуске. Это первый секрет, который
|
||
хранится **в базе**, а не в конфигурации.
|
||
2. **Ключ языковой модели** и адрес шлюза bifrost (`llm-insights-adapter`).
|
||
Утечка оплачивается деньгами.
|
||
3. **Пароль почтового сервера** (`email-notification`).
|
||
4. **Адрес почты пользователя** — приходит от Authelia и хранится у нас
|
||
(`oidc-login`, `email-notification`).
|
||
5. **Статистика потребления** (`usage-accounting`). Текста записей не содержит,
|
||
но говорит, кто и когда пользовался сервисом и сколько; страница расхода
|
||
открыта только владельцу.
|
||
Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с
|
||
самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище,
|
||
пропал, и ключа под него в конфигурации не заводится по той простой причине, что
|
||
заводить нечего.
|
||
|
||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||
2026-08-11 задачей `no-user-filename-in-log`; запрет проверяют тесты приёма по HTTP на
|
||
успешном пути и на пути отказа — они ищут значение, а не имя поля.
|
||
|
||
**Хвост после последней точки остаётся в журнале, и это объявленное изъятие**
|
||
инварианта приватности из [../CLAUDE.md](../CLAUDE.md), а не незакрытый остаток.
|
||
Расширение берётся из имени отправителя дословно (`filepath.Ext`), поэтому имя
|
||
`запись.тайное-слово` отдаёт `тайное-слово`, а `Разговор с Петровым 11.08` —
|
||
`08`. В журнал оно идёт собственным полем, а не в составе имени файла: по нему
|
||
прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация
|
||
расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени
|
||
файла объявлен необратимым и меняется решением человека.
|
||
|
||
**Наружу хвост не выходит.** Метки метрик (`file_extension` у
|
||
`transcriber_input_file_size_bytes`, `source_format` у
|
||
`transcriber_conversion_duration_seconds`) несут расширение, только приведённое к
|
||
закрытому перечню известных форматов; всё прочее заменяется значением `other`.
|
||
Это закрыто задачей `no-user-filename-in-log` 2026-08-11 вместе с самим именем.
|
||
Заодно у метки размера принятой записи пропала ведущая точка (`.mp3` стало
|
||
`mp3`) — форма выровнялась с меткой конвертации, которая точку не носила
|
||
никогда. Ряды, собранные до выкладки, перестают пополняться: график, отобранный
|
||
по старому значению, покажет пустоту, и это не поломка.
|
||
Требование важно тем, что `GET /metrics` открыт вместе с остальным: без
|
||
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
|
||
распоряжался бы анонимный отправитель.
|
||
|
||
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
|
||
клиента — закрыты задачами `no-user-filename-in-log` и
|
||
`local-run-without-telegram-token` 2026-08-13 и потеряли предмет 2026-08-14
|
||
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
|
||
случая остался в [review.md](review.md) — он про класс, а не про Telegram.
|
||
|
||
Путь, который остался, закрыт задачей `telegram-enabled-flag` 2026-08-13, и он
|
||
**шире всякого одного ключа**: до неё утечь мог любой секрет конфига. Отказ разбора файла
|
||
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
|
||
разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка
|
||
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
|
||
шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ
|
||
пересобирается своими словами: путь, строка, столбец и последний ключ, без текста
|
||
библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому
|
||
проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же
|
||
работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»;
|
||
оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек.
|
||
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
|
||
несёт значения **в нынешней версии** библиотеки.
|
||
|
||
## Что вне модели
|
||
|
||
Перечислить явно.
|
||
|
||
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
|
||
Ansible — не наша граница.
|
||
- **Машина разработчика и то, что он на ней поднимает.** На место контура встаёт
|
||
сам сервис: при включённом предохранителе `[server] debug` он подставляет
|
||
заголовки входа значениями из конфига. Прежде эту роль играли отдельные
|
||
процессы — `cmd/oidcstub` с 2026-08-15 по 2026-08-22 и подкоманда
|
||
`cmd/devtools proxy` с 2026-08-22 по 2026-08-23; ни того, ни другой в
|
||
репозитории больше нет. Периметра выкладки отладочный запуск не касается,
|
||
пока предохранитель выключен, а выключен он по умолчанию; кто включил его у
|
||
себя в чужой сети, отвечает за это сам. В оснастке `cmd/devtools` осталась
|
||
одна подкоманда — `resume`, — и в образ она не едет: ступень собирает
|
||
`./cmd/transcriber` поимённо.
|
||
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
|
||
доверяем полностью.
|
||
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
|
||
SpeechKit не рассматривается.
|
||
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
|
||
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
|
||
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
|
||
(паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а
|
||
не замер: распределения длин у сервиса нет, а самая длинная проверенная запись
|
||
— 9,6 МБ ([research/pocketbase-defaults.md](research/pocketbase-defaults.md)).
|
||
Потолок длины стоит открытым вопросом `architecture.md`, «Долгие записи». Квот
|
||
нет — это граница домена, [passport.md](passport.md), «Учёт денег»; расход
|
||
считают `usage-accounting` и `admin-stats-screen`. Для модели угроз отсюда
|
||
следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и
|
||
защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом
|
||
ничем не наблюдается — открытый вопрос `architecture.md`.
|
||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
||
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
|
||
внешней программе, своей песочницы вокруг неё нет.
|
||
- **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются
|
||
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
|
||
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
|
||
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
|
||
Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а
|
||
строки потребления текста не содержат.
|
||
|
||
**Руками запись сегодня убирается только запросом к базе, и порядок в нём
|
||
несущий.** Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее
|
||
чего»), связи приложений с записью обязательны и каскада не имеют, поэтому
|
||
удаление самой строки отвергается базой, пока живы приложения. Порядок такой:
|
||
сперва строки приложений — журнал событий, попытка распознавания, структура,
|
||
тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом
|
||
убираются **одним движением**: подкаталог записи под её идентификатором. Тот,
|
||
кто убрал только файлы, стирает аудио и **оставляет полный текст речи** —
|
||
расшифровку, разбивку по репликам и сохранённый ответ провайдера. До
|
||
`delete-record` это единственный способ, и он ручной целиком.
|