- шаг схемы закрывает поверхность, которую хранилище приносит открытой: собственную регистрацию, вход по паролю и одноразовый код — без этого закрытие приёма обходилось двумя запросами - продление сессии выключено, срок семь суток: иначе отзыв доступа у провайдера до сервиса не доходит никогда - файл записи отдаётся вошедшему по токену файла — пересмотр ADR-2026-08-12-file-link-open-but-not-logged
29 KiB
Модель угроз
Периметр
Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, опрос готовности и файл записи требуют входа
через OIDC у Authelia. Вход развёрнут задачей oidc-login 2026-08-12. Открыты
без входа только проба здоровья и метрики. Находки строятся против этого —
сегодняшнего — периметра.
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. Разграничения по владельцу нет: всякий вошедший видит все
записи и все расшифровки, как видел их прежде аноним. Его заводит задача
record-ownership.
Разграничение доступа в Telegram осталось прежним — белым списком, и с учётной записью приложения он не связан.
Целевой периметр шире сегодняшнего не только входом. Содержимое записи начинает уходить на три новые стороны — языковой модели, в канал уведомлений и на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11 сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда уходит содержимое записи» и «Что вне модели».
Третий сдвиг — панель администратора. Решением от 2026-08-11
(adr) хранилищем
становится PocketBase, и вместе с ним на том же порту появляется панель по
адресу /_/: доступ ко всем записям, всем файлам и всем пользователям разом.
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
панель через Authelia не пускает — у неё свой пароль суперпользователя.
Закрывает панель контур, а не приложение: решением владельца от 2026-08-11
адрес /_/ закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
Четвёртый сдвиг — секрет клиента поселился в базе. Задача oidc-login
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
error_text; база в этом перечне не значится, и запрет не нарушен. Но место
новое: чтение файла базы теперь равносильно чтению секрета клиента.
Отсюда главное следствие, из которого читается всё остальное: 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/<коллекция>/<запись>/<имя>. Поле файла помечено защищённым задачейoidc-login2026-08-12: пройти по ссылке теперь можно только с коротким токеном файла, который выдаётся по сессии, и запрос без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет — токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет остаётся: имя файла в хранилище в журнал не пишется — иначе строка журнала вместе с идентификатором записи собирала бы ссылку целиком и работала бы бессрочно. В журнал идёт расширение своим полем. - Идентификатор задачи — 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 — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой
transcriber_session, живёт семь суток, обесценивается выходом. Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое бессрочно, и семисуточный срок — единственное, чем отзыв доступа у провайдера доходит до сервиса, — не значил бы ничего. Предъявленный заголовокAuthorizationпринимается тоже — это та же сессия и та же проверка, но она названа здесь отдельно, потому что это второй способ предъявить ту же сессию. - Файл записи — короткий токен файла, который узнанный отправитель берёт у
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
коллекции пускает всякого вошедшего, и ссылка
/api/files/...перестала быть правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок здесь «сессия → токен файла → ссылка». - Кто допущен — решает Authelia, а не сервис. Своей проверки группы приложение не делает: кого пускать, определяет правило провайдера на этого клиента. Правило живёт вне репозитория, в настройках выкладки, и по коду его не проверить. Клиент, настроенный слишком широко, открывает сервис всякому, у кого есть учётная запись в общей Authelia. Решение владельца от 2026-08-12.
- Заведение учётной записи — только входом у провайдера. Собственное создание записи, вход по паролю, одноразовый код и восстановление доступа выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и без этого закрытия вход обходился бы двумя запросами.
- Метрики и здоровье —
GET /metricsиGET /healthоткрыты без сессии: её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного прокси — работа выкладки, и сервис на неё не полагается: содержимого записей эти адреса не несут.
Владения записью в модели данных по-прежнему нет: у задачи нет пользователя. Знание UUID задачи и есть право её читать — теперь для всякого вошедшего, а не для всякого встречного.
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из них уже стоит:
| Механизм | Что даёт | Чья задача |
|---|---|---|
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — сделано 2026-08-12 | 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 и всеми уровнями текста. Пока она не сделана, единственный способ убрать запись — руками в базе и в каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.