- data-ownership — обратное право к бессрочному хранению: человек убирает свою запись вместе с файлом, объектом в Object Storage и всеми уровнями текста; - delete-record закрывает все пять пунктов цели: подтверждение, необратимость, чужую запись не тронуть, повторная загрузка того же файла заводит новую задачу, а строки потребления остаются — деньги потрачены; - docs/security.md: пункт «Удаление данных по требованию» перестал говорить, что задачи под это нет.
17 KiB
Модель угроз
Периметр
Сервис открыт наружу: 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).
Что чувствительнее чего
- Содержимое записей и расшифровок. Голосовые сообщения — личная переписка; это самое чувствительное, что здесь есть.
- Токен бота Telegram. Даёт полный доступ к боту и к перепискам с ним.
- Ключи Yandex Cloud —
speech_kit_api_keyи пара ключей Object Storage. Утечка оплачивается деньгами и доступом к бакету. - Белый список пользователей — сам по себе перечень имён.
Всё перечисленное лежит в config.toml. Файл в .gitignore, на сервер его
кладёт Ansible; gitleaks на pre-commit смотрит только индекс коммита.
Целевой периметр добавляет к списку пять записей, и первая из них — новый вид секрета, которого сегодня в проекте нет вовсе:
- Токены пользователей (
api-tokens). Токен даёт права своего владельца целиком. Срока жизни у него нет. В базе лежит только отпечаток, полное значение показывается один раз при выпуске. Это первый секрет, который хранится в базе, а не в конфигурации. - Ключ языковой модели и адрес шлюза bifrost (
llm-insights-adapter). Утечка оплачивается деньгами. - Пароль почтового сервера (
email-notification). - Адрес почты пользователя — приходит от Authelia и хранится у нас
(
oidc-login,email-notification). - Статистика потребления (
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 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача
delete-record: своя запись убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста. Пока она не сделана, единственный способ убрать запись — руками в базе и в каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.