- хвост после последней точки попадает в журнал внутри пути файла и остаётся там осознанно; наружу он выходит только приведённым к перечню - в модели угроз это перестало читаться незакрытым остатком
252 lines
23 KiB
Markdown
252 lines
23 KiB
Markdown
# Модель угроз
|
||
|
||
## Периметр
|
||
|
||
**Сервис открыт наружу: 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` |
|
||
|
||
Каждая из названных задач обязана оставить строку в этом разделе — там это
|
||
записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст
|
||
остаётся в приложении.
|
||
|
||
## Из чего строятся пути и ключи
|
||
|
||
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога.
|
||
Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда
|
||
путь не предполагался.
|
||
|
||
- **Путь на диске** — `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` меняется, и это необратимо.
|
||
- **Раскладку выбирает PocketBase** (`pocketbase-storage`), и плоского каталога
|
||
не остаётся вовсе: файл ложится в
|
||
`pb_data/storage/<коллекция>/<запись>/<имя>_<10 случайных символов>` рядом с
|
||
файлом атрибутов. Имя, данное отправителем, в путь при этом попадает — сегодня
|
||
от него берётся только расширение. Файл уходит не с диска напрямую, а по ссылке
|
||
вида `/api/files/<коллекция>/<запись>/<имя>`; закрытым он становится, только если
|
||
поле помечено защищённым, и тогда нужен отдельный файловый токен. Замер —
|
||
[research/pocketbase.md](research/pocketbase.md).
|
||
- **Имя отправляемого документа** (`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. **Пароль суперпользователя панели** (`pocketbase-storage`). Открывает все
|
||
записи, все файлы и всех пользователей разом, то есть стоит вровень с самым
|
||
чувствительным из списка выше. Второй секрет после токенов пользователей,
|
||
который лежит **не в конфигурации**: его отпечаток хранит сама база.
|
||
|
||
Тексты расшифровок в логи не пишутся — логируется длина текста и
|
||
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
|
||
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. Рост каталога `data/files` при этом ничем не наблюдается —
|
||
открытый вопрос `architecture.md`.
|
||
- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель
|
||
оплачиваются по факту; потолка на пользователя нет по тому же решению.
|
||
- **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан
|
||
внешней программе, своей песочницы вокруг неё нет.
|
||
- **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются
|
||
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
|
||
бессрочно, и тем же днём заведена задача `delete-record`: своя запись
|
||
убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста.
|
||
Пока она не сделана, единственный способ убрать запись — руками в базе и в
|
||
каталоге на сервере. Учёт расхода удалению не подлежит по решению человека:
|
||
деньги потрачены, а строки потребления текста не содержат.
|