Files
transcriber/docs/security.md
T
av cd57b68215 config: включение Telegram разведено с ключом доступа
- в секции [telegram] заведён обязательный ключ enabled: умолчания у него нет,
  файл без него негоден; bot_token стал только ключом доступа и при
  enabled = false не читается вовсе, а пустой при enabled = true роняет старт
- выключенный вход даёт подъём одним входом без единого обращения к Telegram и
  записью INFO вместо прежнего WARN: это выбор владельца, а не отклонение
- отказ разбора файла настроек больше не пересказывает toml — её ParseError
  несёт в тексте разбираемое значение, и оборванная строка секретного ключа
  уносила его в журнал; теперь называются путь, строка, столбец и последний ключ
2026-08-13 21:46:14 +03:00

33 KiB
Raw Blame History

Модель угроз

Периметр

Сервис открыт наружу, но не анонимен: 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 Любой вошедший через OIDC; без сессии — 401 до чтения тела
Идентификатор задачи GET /api/status/:id Любой вошедший через OIDC; без сессии — 401, одинаковый для заведённой и незаведённой задачи
Голосовое, аудио, документ 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-login 2026-08-12: пройти по ссылке теперь можно только с коротким токеном файла, который выдаётся по сессии, и запрос без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет — токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет остаётся: имя файла в хранилище в журнал не пишется — иначе строка журнала вместе с идентификатором записи собирала бы ссылку целиком и работала бы бессрочно. В журнал идёт расширение своим полем.
  • Идентификатор задачи — 15 знаков, выдаёт хранилище. Он же единственное, что защищает GET /api/status/:id.
  • Поверхность самого хранилища. Вместе с переводом наружу выходят /api/collections/..., /api/logs, /api/backups, /api/settings, /api/crons и панель /_/. Правила доступа коллекций оставлены пустыми, то есть доступны они только владельцу панели; коды, снятые прогоном, — database.md, «Коллекции», норма — storage, «Наружу хранилище отдаёт только то, что заказано».

Целевой периметр добавляет сюда три вещи, и все три — от новых задач:

  • Хеш-сумма содержимого (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, обесценивается выходом, срок жизни назначен числом (database.md, «Настройки с числовым значением»). Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера доходит до сервиса, — не значил бы ничего. Предъявленный заголовок 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), и оно же запирает владельца, если список задан неверно: сброса в наборе команд нет.

Что чувствительнее чего

  1. Содержимое записей и расшифровок. Голосовые сообщения — личная переписка; это самое чувствительное, что здесь есть.
  2. Токен бота Telegram. Даёт полный доступ к боту и к перепискам с ним.
  3. Ключи Yandex Cloudspeech_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, а не незакрытый остаток. Расширение берётся из имени отправителя дословно (filepath.Ext), поэтому имя запись.тайное-слово отдаёт тайное-слово, а Разговор с Петровым 11.0808. В журнал оно идёт собственным полем, а не в составе имени файла: по нему прослеживается путь записи. Читает этот журнал владелец сервиса. Нормализация расширения в хранилище — отдельная работа, задачи на неё пока нет: формат имени файла объявлен необратимым и меняется решением человека.

Наружу хвост не выходит. Метки метрик (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. Настоящее имя документа дальше проверки типа файла не идёт.

Токен бота стоит в пути каждого обращения к Bot API (bot<TOKEN>/getFile, …/sendMessage, …/getMe, …/getUpdates) и в ссылке на скачивание (file.Link(token)). Сами адреса нигде не логируются, но до 2026-08-13 их уносил отказ транспорта: *url.Error встраивает адрес целиком, а отказы скачивания и отправки пишутся в журнал. Теперь адрес на границе клиента снимает свой Dointernal/adapter/telegram, NewBot: он чистит отказ, а подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему отдают готовым. Правило — conventions/logging.md, случай — review.md, оракул — internal/adapter/telegram/bot_test.go.

Ещё один путь закрыт задачей local-run-without-telegram-token 2026-08-13, и до неё он был открыт: токен, не разбирающийся как часть адреса (перенос строки из шаблона выкладки, невычищенная %-последовательность), роняет сборку клиента раньше обращения к нему — то есть мимо чистки на границе клиента. Отказ конструктора теперь чистится отдельно. Нашло это ревью кода тремя проходами независимо; оракул — там же, в bot_test.go.

Третий путь закрыт задачей telegram-enabled-flag 2026-08-13, и он шире токена бота: до неё утечь мог любой секрет конфига. Отказ разбора файла настроек пересказывался как есть, а библиотека разбора собирает текст отказа из разбираемого куска — toml.ParseError кладёт в сообщение само значение. Строка секретного ключа с оборванной кавычкой — типовая поломка криво собранного шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ пересобирается своими словами: путь, строка, столбец и последний ключ, без текста библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же работе. Правило — conventions/config.md, «Секреты»; оракулы — internal/config/config_test.go, проверки поломанного файла настроек. Остаточный риск назван там же: разрез опирается на то, какое семейство отказов несёт значения в нынешней версии библиотеки.

Что вне модели

Перечислить явно.

  • Атака на сам сервер и на контур. Компрометация хоста, прокси, Docker и Ansible — не наша граница.
  • Злоупотребление со стороны пользователя из белого списка. Приглашённому доверяем полностью.
  • Достоверность расшифровки. Подмена или искажение текста на стороне SpeechKit не рассматривается.
  • Стойкость к целенаправленной нагрузке. Ограничения по числу запросов и по размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся.
  • Исчерпание диска приглашёнными. Записи и тексты хранятся бессрочно (паспорт, 2026-08-11). Шестичасовая запись весит единицы гигабайт — оценка, а не замер: распределения длин у сервиса нет, а самая длинная проверенная запись — 9,6 МБ (research/pocketbase-defaults.md). Потолок длины стоит открытым вопросом architecture.md, «Долгие записи». Квот нет — это граница домена, passport.md, «Учёт денег»; расход считают usage-accounting и admin-stats-screen. Для модели угроз отсюда следует одно: ни числом запросов, ни размером записи вошедший не ограничен, и защищаться от исчерпания диска мы не пытаемся. Рост каталога данных при этом ничем не наблюдается — открытый вопрос architecture.md.
  • Перерасход денег на внешних сервисах. Распознавание и языковая модель оплачиваются по факту; потолка на пользователя нет по тому же решению.
  • Стойкость ffmpeg к вредоносному входу. Разбор чужого формата отдан внешней программе, своей песочницы вокруг неё нет.
  • Удаление данных по требованию. Ни файлы, ни расшифровки не удаляются вовсе. С 2026-08-11 это уже не недосмотр, а следствие решения хранить бессрочно, и тем же днём заведена задача delete-record: своя запись убирается вместе с файлом, объектом в Object Storage и всеми уровнями текста. Пока она не сделана, единственный способ убрать запись — руками в базе и в каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.