- хвост после последней точки попадает в журнал внутри пути файла и остаётся там осознанно; наружу он выходит только приведённым к перечню - в модели угроз это перестало читаться незакрытым остатком
23 KiB
Модель угроз
Периметр
Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и аутентификации не делает ни прокси, ни само приложение. Находки строятся против этого — сегодняшнего — периметра.
Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный вход для программ по личным токенам, два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и страницу расхода. Он не развёрнут; описанное ниже разграничение доступа относится только к Telegram.
Целевой периметр шире сегодняшнего не только входом. Содержимое записи начинает уходить на три новые стороны — языковой модели, в канал уведомлений и на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11 сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда уходит содержимое записи» и «Что вне модели».
Третий сдвиг — панель администратора. Решением от 2026-08-11
(adr) хранилищем
становится 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. - Имя отправляемого документа (
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), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет.
Что чувствительнее чего
- Содержимое записей и расшифровок. Голосовые сообщения — личная переписка; это самое чувствительное, что здесь есть.
- Токен бота 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). Текста записей не содержит, но говорит, кто и когда пользовался сервисом и сколько; страница расхода открыта только владельцу. - Пароль суперпользователя панели (
pocketbase-storage). Открывает все записи, все файлы и всех пользователей разом, то есть стоит вровень с самым чувствительным из списка выше. Второй секрет после токенов пользователей, который лежит не в конфигурации: его отпечаток хранит сама база.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
2026-08-11 задачей no-user-filename-in-log; запрет проверяют тесты приёма по HTTP на
успешном пути и на пути отказа — они ищут значение, а не имя поля.
Хвост после последней точки остаётся в журнале, и это объявленное изъятие
инварианта приватности из ../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 и всеми уровнями текста. Пока она не сделана, единственный способ убрать запись — руками в базе и в каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.