# Модель угроз ## Периметр **Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через обратный прокси, а приём записи, опрос готовности и файл записи требуют входа через OIDC у Authelia.** Вход развёрнут задачей `oidc-login` 2026-08-12. Открыты без входа только проба здоровья и метрики. Находки строятся против этого — сегодняшнего — периметра. Целевой периметр добавляет к нему отдельный вход для программ по личным токенам и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и страницу расхода. **Разграничение по владельцу записи заведено 2026-08-14** задачей `record-ownership`: и опрос готовности, и файл записи сужены владельцем записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня доступа — страницы расхода для владельца сервиса. Записи, принятые ботом, владельца не имеют и по API не достаются никому: связи чата с учётной записью приложения нет, её заводит `telegram-account-link`. Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной записью приложения он не связан. **Целевой периметр шире сегодняшнего не только входом.** Содержимое записи начинает уходить на три новые стороны — языковой модели, в канал уведомлений и на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11 сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда уходит содержимое записи» и «Что вне модели». **Третий сдвиг — панель администратора.** Решением от 2026-08-11 ([adr](adr/ADR-2026-08-11-pocketbase-storage-with-admin-panel.md)) хранилищем становится PocketBase, и вместе с ним на том же порту появляется панель по адресу `/_/`: доступ ко всем записям, всем файлам и всем пользователям разом. Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в панель через Authelia не пускает — у неё свой пароль суперпользователя. **Закрывает панель контур, а не приложение:** решением владельца от 2026-08-11 адрес `/_/` закрывает Authelia на обратном прокси, пропуская только группу администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а она вне модели («Что вне модели», строка про контур). **Четвёртый сдвиг — секрет клиента поселился в базе.** Задача `oidc-login` 2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в настройки коллекции пользователей, приводя их к конфигу при каждом подъёме (применённый шаг схемы не переписывается, и положенный им секрет не пережил бы ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в `error_text`; база в этом перечне не значится, и запрет не нарушен. Но место новое: **чтение файла базы теперь равносильно чтению секрета клиента**. Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio` требует входа, а число запросов и размер файла по-прежнему ничем не ограничены**. Вошедший не ограничен ни в том, ни в другом, и тратит наши деньги на распознавание столько, сколько захочет. ## Недоверенный вход Что приходит извне и каким каналом. | Вход | Канал | Кто может слать | | --- | --- | --- | | Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой вошедший через OIDC; без сессии — `401` до чтения тела | | Идентификатор задачи | `GET /api/status/:id` | Любой вошедший через OIDC; без сессии — `401`, одинаковый для заведённой и незаведённой задачи | | Голосовое, аудио, документ | Telegram, длинный опрос | Любой пользователь Telegram; обрабатывается только из белого списка | | Имя файла в Telegram | Поле `file_path` ответа Bot API | Telegram, а через него — отправитель | | Содержимое аудио | Файл, скармливаемый `ffmpeg` и `ffprobe` | Отправитель по любому из каналов | | Текст расшифровки | Поток gRPC от SpeechKit | Yandex, а через него — содержимое записи | Что добавится вместе с целевым периметром — каждый вход появляется своей задачей, и до неё его нет: | Вход | Канал | Кто может слать | Чья задача | | --- | --- | --- | --- | | Токен доступа | Заголовок запроса к `/api/` | Любой из интернета | `api-tokens` | | Данные учётной записи: идентификатор, почта, группы | Ответ Authelia по OIDC | Провайдер, а через него — то, что записано в учётной записи | `oidc-login` | | Заголовок, темы, пересказ | Ответ языковой модели | Внешняя модель, а через неё — содержимое записи | `llm-insights-adapter` | | Вычитанный текст | Ответ той же модели | То же | `literary-text-level` | | Настройки пользователя | Эндпоинт записи своих настроек | Вошедший пользователь | `settings-screen` | | Хеш-сумма файла | Поле запроса приёма | Отправитель — и она же решает, отдать ли прежнюю запись | `dedup-by-content-hash` | Ответ языковой модели опаснее прочего в этом списке: он приходит текстом, идёт в заголовок записи и оттуда на экран — то есть внешний сервис пишет то, что увидит человек. ## Куда уходит содержимое записи Сегодня запись и её текст покидают наш сервер тремя путями: файл уезжает в Yandex Object Storage, оттуда его читает SpeechKit, а текст возвращается в Telegram отправителю. Целевой периметр добавляет три пути, каждый — своей задачей: | Куда | Что уходит | Чья задача | | --- | --- | --- | | Языковая модель за шлюзом bifrost | Текст расшифровки целиком | `llm-insights-adapter`, затем `literary-text-level` | | Канал уведомлений (ntfy через apprise) | Готовый текст либо причина отказа | `ntfy-delivery` | | Почтовый сервер | Готовый текст либо причина отказа, на адрес из учётной записи | `email-notification` | Каждая из названных задач обязана оставить строку в этом разделе — там это записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст остаётся в приложении. ## Из чего строятся пути и ключи Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога. Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда путь не предполагался. - **Путь на диске** выбирает хранилище: `data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис** — `<расширение>`, — а умолчание PocketBase, строящее имя из имени отправителя, не применяется: имя отправителя в хранилище не попадает. Расширение берётся из имени отправителя через `filepath.Ext` без проверки списком; `filepath.Ext` режет по последней точке и не пропускает разделитель каталогов, но это единственное, что стоит между входом и именем файла. - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с расширением. Бакет один на все записи, префикса по пользователю нет. - **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла помечено защищённым задачей `oidc-login` 2026-08-12: пройти по ссылке теперь можно только с коротким токеном файла, который выдаётся по сессии, и запрос без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет — токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет остаётся: **имя файла в хранилище в журнал не пишется** — иначе строка журнала вместе с идентификатором записи собирала бы ссылку целиком и работала бы бессрочно. В журнал идёт расширение своим полем. - **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное, что защищает `GET /api/status/:id`. - **Поверхность самого хранилища.** Вместе с переводом наружу выходят `/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`, `/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то есть доступны они только владельцу панели; коды, снятые прогоном, — [database.md](database.md), «Коллекции», норма — [storage](../openspec/specs/storage/spec.md), «Наружу хранилище отдаёт только то, что заказано». Целевой периметр добавляет сюда три вещи, и все три — от новых задач: - **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска прежней записи. Ищется она **в пределах одного пользователя**: глобальный поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и заодно сообщал бы, что запись у кого-то уже есть. - **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же плоский каталог — раскладка каталога данных меняется, и это необратимо. - **Имя отправляемого документа** (`long-text-delivery`) собирается из идентификатора задачи: имя, данное пользователем, в него не попадает. ## Что разграничивает доступ - **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя с фамилией), а не с числовым идентификатором. Имя пользователя Telegram меняется владельцем в любой момент: список привязан к изменяемому значению. - **HTTP API** — сессия, заведённая входом через OIDC у Authelia. Предъявляется кукой `transcriber_session`, обесценивается выходом, срок жизни назначен числом ([database.md](database.md), «Настройки с числовым значением»). Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера доходит до сервиса, — не значил бы ничего. Предъявленный заголовок `Authorization` принимается тоже — это та же сессия и та же проверка, но она названа здесь отдельно, потому что это второй способ предъявить ту же сессию. - **Файл записи** — короткий токен файла, который узнанный отправитель берёт у хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра коллекции пускает всякого вошедшего, и ссылка `/api/files/...` перестала быть правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок здесь «сессия → токен файла → ссылка». - **Кто допущен** — **решает Authelia, а не сервис.** Своей проверки группы приложение не делает: кого пускать, определяет правило провайдера на этого клиента. Правило живёт **вне репозитория**, в настройках выкладки, и по коду его не проверить. Клиент, настроенный слишком широко, открывает сервис всякому, у кого есть учётная запись в общей Authelia. Решение владельца от 2026-08-12. - **Заведение учётной записи** — только входом у провайдера. Собственное создание записи, вход по паролю, одноразовый код и восстановление доступа выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и без этого закрытия вход обходился бы двумя запросами. - **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты без сессии: её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного прокси — работа выкладки, и сервис на неё не полагается: содержимого записей эти адреса не несут. Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла есть владелец. Знание идентификатора задачи правом её читать больше не является — читает её тот, кто её принёс. Целевой периметр заводит четыре механизма вместо одного белого списка; первый из них уже стоит: | Механизм | Что даёт | Чья задача | | --- | --- | --- | | Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — **сделано 2026-08-12** | `oidc-login` | | Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — **сделано 2026-08-14** | `record-ownership` | | Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | Белый список Telegram при этом перестаёт быть отдельным механизмом: право писать боту выводится из учётной записи (`telegram-account-link`). Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». Откуда он берётся — из группы OIDC или из конфигурации — не решено (`admin-stats-screen`). **Панель администратора в эту таблицу не входит и разграничению не подчиняется.** Суперпользователь PocketBase видит все записи, все файлы и всех пользователей мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia. Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя: обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение по списку адресов (`superuserIPs`), и оно же запирает владельца, если список задан неверно: сброса в наборе команд нет. ## Что чувствительнее чего 1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка; это самое чувствительное, что здесь есть. 2. **Токен бота Telegram.** Даёт полный доступ к боту и к перепискам с ним. 3. **Ключи Yandex Cloud** — `speech_kit_api_key` и пара ключей Object Storage. Утечка оплачивается деньгами и доступом к бакету. 4. **Белый список пользователей** — сам по себе перечень имён. Всё перечисленное лежит в `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`). Текста записей не содержит, но говорит, кто и когда пользовался сервисом и сколько; страница расхода открыта только владельцу. 6. **Пароль владельца от панели.** Открывает все записи, все файлы и всех пользователей разом, то есть стоит вровень с самым чувствительным из списка выше. Второй секрет после токенов пользователей, который лежит **не в конфигурации**: его отпечаток хранит сама база, а задаёт пароль сам владелец по приглашению, которое сервис печатает в журнал при первом запуске. У приглашения тридцать минут жизни, и после того как владелец заведён, оно не печатается вовсе — иначе строка журнала отдавала бы панель всякому его читателю навсегда. Тексты расшифровок в логи не пишутся — логируется длина текста и идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано 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` открыт вместе с остальным: без приведения хвост читал бы кто угодно из интернета, а множеством значений метки распоряжался бы анонимный отправитель. Приём из Telegram имени, данного человеком, до сервиса не доводит: оттуда приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки типа файла не идёт. Токен бота стоит в пути **каждого** обращения к Bot API (`bot/getFile`, `…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание (`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает свой `Do` — `internal/adapter/telegram`, `NewBot`: он чистит отказ, а подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему отдают готовым. Правило — [conventions/logging.md](conventions/logging.md), случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`. Ещё один путь закрыт задачей `local-run-without-telegram-token` 2026-08-13, и до неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из шаблона выкладки, невычищенная `%`-последовательность), роняет сборку клиента **раньше** обращения к нему — то есть мимо чистки на границе клиента. Отказ конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами независимо; оракул — там же, в `bot_test.go`. Третий путь закрыт задачей `telegram-enabled-flag` 2026-08-13, и он **шире токена бота**: до неё утечь мог любой секрет конфига. Отказ разбора файла настроек пересказывался как есть, а библиотека разбора собирает текст отказа из разбираемого куска — `toml.ParseError` кладёт в сообщение само значение. Строка секретного ключа с оборванной кавычкой — типовая поломка криво собранного шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ пересобирается своими словами: путь, строка, столбец и последний ключ, без текста библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же работе. Правило — [conventions/config.md](conventions/config.md), «Секреты»; оракулы — `internal/config/config_test.go`, проверки поломанного файла настроек. Остаточный риск назван там же: разрез опирается на то, какое семейство отказов несёт значения **в нынешней версии** библиотеки. ## Что вне модели Перечислить явно. - **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и Ansible — не наша граница. - **Злоупотребление со стороны пользователя из белого списка.** Приглашённому доверяем полностью. - **Достоверность расшифровки.** Подмена или искажение текста на стороне 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 и всеми уровнями текста. Пока она не сделана, единственный способ убрать запись — руками в базе и в каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.