- записи, метаданные и файлы съехались под один каталог данных; появилась панель владельца, а gin, goqu, goose и требование CGO ушли - захват задачи стал одним запросом с RETURNING; заведены число попыток, состояние dead и нарастающая пауза вместо признака is_error - имя файла в хранилище задаёт сервис и в журнал не идёт: вместе с идентификатором записи оно собирало бы ссылку на скачивание
24 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 |
Каждая из названных задач обязана оставить строку в этом разделе — там это записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст остаётся в приложении.
Из чего строятся пути и ключи
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога. Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда путь не предполагался.
- Путь на диске выбирает хранилище:
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), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет.
Что чувствительнее чего
- Содержимое записей и расшифровок. Голосовые сообщения — личная переписка; это самое чувствительное, что здесь есть.
- Токен бота 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). Текста записей не содержит, но говорит, кто и когда пользовался сервисом и сколько; страница расхода открыта только владельцу. - Пароль владельца от панели. Открывает все записи, все файлы и всех пользователей разом, то есть стоит вровень с самым чувствительным из списка выше. Второй секрет после токенов пользователей, который лежит не в конфигурации: его отпечаток хранит сама база, а задаёт пароль сам владелец по приглашению, которое сервис печатает в журнал при первом запуске. У приглашения тридцать минут жизни, и после того как владелец заведён, оно не печатается вовсе — иначе строка журнала отдавала бы панель всякому его читателю навсегда.
Тексты расшифровок в логи не пишутся — логируется длина текста и
идентификаторы. Имя файла, данное отправителем, из журнала приёма убрано
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. Рост каталога данных при этом ничем не наблюдается — открытый вопросarchitecture.md. - Перерасход денег на внешних сервисах. Распознавание и языковая модель оплачиваются по факту; потолка на пользователя нет по тому же решению.
- Стойкость
ffmpegк вредоносному входу. Разбор чужого формата отдан внешней программе, своей песочницы вокруг неё нет. - Удаление данных по требованию. Ни файлы, ни расшифровки не удаляются
вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить
бессрочно, и тем же днём заведена задача
delete-record: своя запись убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста. Пока она не сделана, единственный способ убрать запись — руками в базе и в каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.