Files
transcriber/docs/security.md
T
av ba7b4f37a6 docs: устранены расхождения документов между собой и с кодом
- README разгружен: контракт HTTP API, таблицы БД, состояния задач и белый
  список отданы нормативным источникам ссылками
- в `architecture.md` и `review.md` числа и механика захвата задачи заменены
  ссылками на `database.md`, а описание конвейера ревью — на прогон от 2026-08-11
- в `CLAUDE.md` и `security.md` поправлены границы домена, оценка объёма записи
  и адрес очереди задач
2026-08-11 12:24:41 +03:00

17 KiB
Raw Blame History

Модель угроз

Периметр

Сервис открыт наружу: HTTP-порт опубликован в интернет через обратный прокси, и аутентификации не делает ни прокси, ни само приложение. Находки строятся против этого — сегодняшнего — периметра.

Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный вход для программ по личным токенам, два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и страницу расхода. Он не развёрнут; описанное ниже разграничение доступа относится только к Telegram.

Целевой периметр шире сегодняшнего не только входом. Содержимое записи начинает уходить на три новые стороны — языковой модели, в канал уведомлений и на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11 сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда уходит содержимое записи» и «Что вне модели».

Отсюда главное следствие, из которого читается всё остальное: 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 меняется, и это необратимо.
  • Имя отправляемого документа (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).

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

  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). Текста записей не содержит, но говорит, кто и когда пользовался сервисом и сколько; страница расхода открыта только владельцу.

Тексты расшифровок в логи не пишутся — логируется длина текста и идентификаторы. Имя файла, данное отправителем, пишется: строка internal/service/transcribe.go:107 кладёт file_name на общем шаге заведения задачи, то есть для обоих входов. Это нарушение инварианта приватности из CLAUDE.md, оно объявлено критическим, и чинит его задача no-user-filename-in-log; её место в очереди — tasks/BACKLOG.md.

Токен бота попадает в 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 и всеми уровнями текста. Пока она не сделана, единственный способ убрать запись — руками в базе и в каталоге на сервере. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.