Files
transcriber/docs/security.md
T
av 01cc31d45f хранилище, файлы записей и очередь переведены на встроенную PocketBase
- записи, метаданные и файлы съехались под один каталог данных; появилась
  панель владельца, а gin, goqu, goose и требование CGO ушли
- захват задачи стал одним запросом с RETURNING; заведены число попыток,
  состояние dead и нарастающая пауза вместо признака is_error
- имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с
  идентификатором записи оно собирало бы ссылку на скачивание
2026-08-12 08:31:59 +03:00

259 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Модель угроз
## Периметр
**Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и
аутентификации не делает ни прокси, ни само приложение.** Находки строятся против
этого — сегодняшнего — периметра.
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный
вход для программ по личным токенам, два уровня доступа — пользователь видит
свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут;
описанное ниже разграничение доступа относится только к 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 на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
Отсюда главное следствие, из которого читается всё остальное: **`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` |
Каждая из названных задач обязана оставить строку в этом разделе — там это
записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст
остаётся в приложении.
## Из чего строятся пути и ключи
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога.
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
путь не предполагался.
- **Путь на диске** выбирает хранилище:
`data/storage/<коллекция>/<запись>/<имя>`. **Имя задаёт сервис**
`<uuid><расширение>`, — а умолчание PocketBase, строящее имя из имени
отправителя, не применяется: имя отправителя в хранилище не попадает.
Расширение берётся из имени отправителя через `filepath.Ext` без проверки
списком; `filepath.Ext` режет по последней точке и не пропускает разделитель
каталогов, но это единственное, что стоит между входом и именем файла.
- **Ключ объекта в Object Storage** — то же имя файла, то есть UUID с
расширением. Бакет один на все записи, префикса по пользователю нет.
- **Ссылка на файл** — `/api/files/<коллекция>/<запись>/<имя>`. Поле файла не
помечено защищённым, поэтому ссылка сама по себе и есть право пройти по ней, а
отзыва у неё нет. Отсюда запрет: **имя файла в хранилище в журнал не пишется**
— иначе строка журнала вместе с идентификатором записи собирала бы ссылку
целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
- **Идентификатор задачи** — 15 знаков, выдаёт хранилище. Он же единственное,
что защищает `GET /api/status/:id`.
- **Поверхность самого хранилища.** Вместе с переводом наружу выходят
`/api/collections/...`, `/api/logs`, `/api/backups`, `/api/settings`,
`/api/crons` и панель `/_/`. Правила доступа коллекций оставлены пустыми, то
есть доступны они только владельцу панели; проверено прогоном — записи отдают
`403`, служебные разделы `401`.
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
- **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска
прежней записи. Ищется она **в пределах одного пользователя**: глобальный
поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и
заодно сообщал бы, что запись у кого-то уже есть.
- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же
плоский каталог — раскладка каталога данных меняется, и это необратимо.
- **Имя отправляемого документа** (`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`).
**Панель администратора в эту таблицу не входит и разграничению не подчиняется.**
Суперпользователь 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. Настоящее имя документа дальше проверки
типа файла не идёт.
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
нигде не логируется.
## Что вне модели
Перечислить явно.
- **Атака на сам сервер и на контур.** Компрометация хоста, прокси, Docker и
Ansible — не наша граница.
- **Злоупотребление со стороны пользователя из белого списка.** Приглашённому
доверяем полностью.
- **Достоверность расшифровки.** Подмена или искажение текста на стороне
SpeechKit не рассматривается.
- **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по
размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно
(паспорт, 2026-08-11), шестичасовая запись весит единицы гигабайт — оценка, а не
замер: `research/` пуст, потолок длины стоит открытым вопросом
`architecture.md`, «Долгие записи», — а квот нет и не будет: решено считать расход и показывать его владельцу, а не отказывать
(цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в
Authelia. Рост каталога данных при этом ничем не наблюдается —
открытый вопрос `architecture.md`.
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
оплачиваются по факту; потолка на пользователя нет по тому же решению.
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
внешней программе, своей песочницы вокруг неё нет.
- **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
Пока она не сделана, единственный способ убрать запись — руками в базе и в
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека:
деньги потрачены, а строки потребления текста не содержат.