# Модель угроз ## Периметр **Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и аутентификации не делает ни прокси, ни само приложение.** Находки строятся против этого — сегодняшнего — периметра. Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный вход для программ по личным токенам, два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут; описанное ниже разграничение доступа относится только к Telegram. **Целевой периметр шире сегодняшнего не только входом.** Содержимое записи начинает уходить на три новые стороны — языковой модели, в канал уведомлений и на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11 сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда уходит содержимое записи» и «Что вне модели». Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio` доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу запросов и не ограничен по размеру файла. ## Недоверенный вход Что приходит извне и каким каналом. | Вход | Канал | Кто может слать | | --- | --- | --- | | Аудиофайл и его имя | `POST /api/audio`, multipart-поле `audio` | Любой из интернета | | Идентификатор задачи | `GET /api/status/:id` | Любой из интернета | | Голосовое, аудио, документ | 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` | Каждая из названных задач обязана оставить строку в этом разделе — там это записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст остаётся в приложении. ## Из чего строятся пути и ключи Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога. Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда путь не предполагался. - **Путь на диске** — `filepath.Join(cfg.Storage.Path, fileId + ext)`, где `fileId` наш UUID, а **`ext` берётся из имени файла отправителя** через `filepath.Ext`. Расширение в путь попадает без проверки списком; `filepath.Ext` режет по последней точке и не пропускает разделитель каталогов, но это единственное, что стоит между входом и именем файла. - **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с расширением. Бакет один на все записи, префикса по пользователю нет. - **Каталог** один и плоский: `data/files` целиком, вложенности нет. - **Идентификатор задачи** — UUID v4. Он же единственное, что защищает `GET /api/status/:id`. Целевой периметр добавляет сюда три вещи, и все три — от новых задач: - **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска прежней записи. Ищется она **в пределах одного пользователя**: глобальный поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и заодно сообщал бы, что запись у кого-то уже есть. - **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же плоский каталог — раскладка `data/files` меняется, и это необратимо. - **Имя отправляемого документа** (`long-text-delivery`) собирается из идентификатора задачи: имя, данное пользователем, в него не попадает. ## Что разграничивает доступ - **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой автора сообщения (`update.Message.From.String()`, то есть `@username` либо имя с фамилией), а не с числовым идентификатором. Имя пользователя Telegram меняется владельцем в любой момент: список привязан к изменяемому значению. - **HTTP API** — ничего. Ни ключа, ни сессии, ни ограничения по адресу. - **Метрики и здоровье** — `GET /metrics` и `GET /health` открыты вместе с остальным. Владения записью в модели данных нет: у задачи нет пользователя. Пока API анонимен, знание UUID задачи и есть право её читать. Целевой периметр заводит четыре механизма вместо одного белого списка: | Механизм | Что даёт | Чья задача | | --- | --- | --- | | Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` | | Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` | | Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | | Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | Белый список Telegram при этом перестаёт быть отдельным механизмом: право писать боту выводится из учётной записи (`telegram-account-link`). Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». Откуда он берётся — из группы OIDC или из конфигурации — не решено (`admin-stats-screen`). ## Что чувствительнее чего 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`). Текста записей не содержит, но говорит, кто и когда пользовался сервисом и сколько; страница расхода открыта только владельцу. Тексты расшифровок в логи не пишутся — логируется длина текста и идентификаторы. **Имя файла, данное отправителем, пишется**: строка `internal/service/transcribe.go:107` кладёт `file_name` на общем шаге заведения задачи, то есть для обоих входов. Это нарушение инварианта приватности из `CLAUDE.md`, оно объявлено критическим, и чинит его задача `no-user-filename-in-log` — первая строка очереди. Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL нигде не логируется. ## Что вне модели Перечислить явно. - **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и Ansible — не наша граница. - **Злоупотребление со стороны пользователя из белого списка.** Приглашённому доверяем полностью. - **Достоверность расшифровки.** Подмена или искажение текста на стороне SpeechKit не рассматривается. - **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся. - **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно (паспорт, 2026-08-11), шестичасовая запись весит гигабайты, а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в Authelia. Рост каталога `data/files` при этом ничем не наблюдается — открытый вопрос `architecture.md`. - **Перерасход денег на внешних сервисах.** Распознавание и языковая модель оплачиваются по факту; потолка на пользователя нет по тому же решению. - **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан внешней программе, своей песочницы вокруг неё нет. - **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются вовсе, и удаление по требованию в задачах не стоит. С 2026-08-11 это уже не недосмотр, а следствие решения хранить бессрочно — но требование «убери мою запись» им не закрыто, и задачи под него нет.