- main.go и journal_route_test.go переехали в cmd/transcriber без правок содержимого; образ собирает ./cmd/transcriber поимённо - cmd/oidcstub отвечает на /authorize, /token и /userinfo, проверок не делает и слушает петлевой адрес: войти без Authelia стало чем - ступень сборки приложения переехала на node:24 с alpine — musl ждёт ответа на AAAA, которого нет, и npm ci висел вместо отказа
39 KiB
Модель угроз
Периметр
Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через
обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют входа
через OIDC у Authelia. Вход развёрнут задачей oidc-login 2026-08-12. Без
входа открыты проба здоровья, метрики и — с 2026-08-15, задачей spa-skeleton —
само приложение: его разметка и её ресурсы, а вместе с ними всякий путь, не
принадлежащий ни одному корню сервиса. Иначе не вошедший не дошёл бы до входа
вовсе: закрытая сессией разметка отдала бы ему отказ вместо экрана. Данных
открытость не касается — всякий адрес под корнем приложения сессии по-прежнему
требует. Находки строятся против этого — сегодняшнего — периметра.
Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения,
а каталог его лежит в .gitignore и не судится ничем: всё, что окажется там у
собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и
отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное
браузеру сервису нечем.
Целевой периметр добавляет к нему отдельный вход для программ по личным токенам
и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и
страницу расхода. Разграничение по владельцу записи заведено 2026-08-14
задачей record-ownership: и чтение записи, и файл записи сужены владельцем
записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня
доступа — страницы расхода для владельца сервиса.
Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения не принимает, и держит это схема хранилища. Прежде такие записи заводил вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран вместе с этим исключением.
Целевой периметр шире сегодняшнего не только входом. Содержимое записи начинает уходить на три новые стороны — языковой модели, в канал уведомлений и на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11 сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда уходит содержимое записи» и «Что вне модели».
Третий сдвиг — панель администратора. Решением от 2026-08-11
(adr) хранилищем
становится PocketBase, и вместе с ним на том же порту появляется панель по
адресу /_/: доступ ко всем записям, всем файлам и всем пользователям разом.
Порт опубликован в интернет через обратный прокси, а сама PocketBase вход в
панель через Authelia не пускает — у неё свой пароль суперпользователя.
Закрывает панель контур, а не приложение: решением владельца от 2026-08-11
адрес /_/ закрывает Authelia на обратном прокси, пропуская только группу
администраторов. Задачи в беклоге у этого нет — работа принадлежит выкладке, а
она вне модели («Что вне модели», строка про контур).
И этот барьер обходится подменой одного знака. Маршрутизатор сравнивает
сегменты пути после раскодирования, поэтому /%5f/ попадает в ту же группу,
что и /_/, а правило прокси написано на литерал и такой формы не видит.
Проверено прогоном 2026-08-15 ревью задачи spa-skeleton: обе формы отвечают
байт в байт, и весь клиент панели грузится анониму. Вход в приложение при этом
не обходится — /%61pp/me отвечает 401. Дефект старше задачи, которая его
нашла, и сегодня не закрыт: лечение — приведение пути к канонической форме на
стороне сервиса, и глухая проверка тут не годится, потому что сломает скачивание
файлов с пробелами и не-латиницей в имени. Половину пути проверить нечем: правило
прокси живёт в pet-project-server, вне этого репозитория.
Четвёртый сдвиг — секрет клиента поселился в базе. Задача oidc-login
2026-08-12 кладёт адреса провайдера, идентификатор клиента и его секрет в
настройки коллекции пользователей, приводя их к конфигу при каждом подъёме
(применённый шаг схемы не переписывается, и положенный им секрет не пережил бы
ротации). Инвариант проекта запрещает секрету попадать в git, в лог, в ответ и в
error_text; база в этом перечне не значится, и запрет не нарушен. Но место
новое: чтение файла базы теперь равносильно чтению секрета клиента.
Отсюда главное следствие, из которого читается всё остальное: POST /app/audiorecords требует входа, а размер файла ограничен потолком записи, число
же запросов ограничено только частотой. Вошедший тратит наши деньги на
распознавание столько, сколько захочет: ограничитель частоты под корнем
приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму
по-прежнему нет — её заводит per-user-size-quota.
Недоверенный вход
Что приходит извне и каким каналом.
| Вход | Канал | Кто может слать |
|---|---|---|
| Аудиофайл и его имя | POST /app/audiorecords, multipart-поле audio |
Любой вошедший через OIDC; без сессии — 401 до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков |
| Идентификатор записи | GET /app/audiorecords/{id} и /text |
Любой вошедший через OIDC; без сессии — 401, одинаковый для заведённой и незаведённой записи |
| Ключ страницы, размер страницы, состояние отбора | GET /app/audiorecords, параметры запроса |
Любой вошедший; нечитаемый ключ и негодный размер дают 400, а не молчаливую первую страницу |
| Вид текста | GET /app/audiorecords/{id}/text, параметр view |
Любой вошедший; значение вне закрытого перечня даёт 400 |
| Содержимое аудио | Файл, скармливаемый 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 — исчез 2026-08-14 вместе с убранным входом: текст теперь достаётся только своим адресом и в панели владельца.
Целевой периметр добавляет три пути, каждый — своей задачей:
| Куда | Что уходит | Чья задача |
|---|---|---|
| Языковая модель за шлюзом bifrost | Текст расшифровки целиком | llm-insights-adapter, затем literary-text-level |
| Канал уведомлений (ntfy через apprise) | Готовый текст либо причина отказа | ntfy-delivery |
| Почтовый сервер | Готовый текст либо причина отказа, на адрес из учётной записи | email-notification |
Каждая из названных задач обязана оставить строку в этом разделе — там это записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст остаётся в приложении.
Из чего строятся пути и ключи
Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога. Отсюда возможен выход за пределы каталога хранения — запись файла туда, куда путь не предполагался.
- Путь на диске выбирает хранилище:
data/storage/<коллекция>/<запись>/<имя>. Имя задаёт сервис —<uuid><расширение>, — а умолчание PocketBase, строящее имя из имени отправителя, не применяется: имя отправителя в хранилище не попадает. Расширение берётся из имени отправителя черезfilepath.Extбез проверки списком;filepath.Extрежет по последней точке и не пропускает разделитель каталогов, но это единственное, что стоит между входом и именем файла. - Ключ объекта в Object Storage — то же имя файла, то есть UUID с расширением. Бакет один на все записи, префикса по пользователю нет. С 2026-08-14 копия там файлом записи не считается: она существует лишь потому, что провайдер читает аудио по адресу, и её ключ живёт в строке попытки распознавания.
- Вторая раскладка файла на диске появилась 2026-08-14 вместе с сохранённым
ответом провайдера:
data/storage/<recognitions>/<попытка>/<имя>.payload. Имя задаёт сервис, как и у аудио. Содержимое там — полный текст речи, а не метаданные, поэтому поле помечено защищённым, правило просмотра коллекции оставлено пустым, и ссылка на вложение подпадает под тот же запрет, что и ссылка на аудио: в журнал она не пишется. Проверено прогоном: без сессии, с чужим и со своим токеном файла ссылка отвечает «не найдено». - Ссылка на файл —
/api/files/<коллекция>/<запись>/<имя>. Поле файла помечено защищённым задачейoidc-login2026-08-12: пройти по ссылке теперь можно только с коротким токеном файла, который выдаётся по сессии, и запрос без него получает «не найдено». Сама ссылка отзыва по-прежнему не имеет — токен сужает круг и живёт недолго, но выданное не отзывается. Отсюда запрет остаётся: имя файла в хранилище в журнал не пишется — иначе строка журнала вместе с идентификатором записи собирала бы ссылку целиком и работала бы бессрочно. В журнал идёт расширение своим полем. - Идентификатор записи — 15 знаков, выдаёт хранилище. Он же единственное, что защищает карточку записи и её текст.
- Поверхность самого хранилища. Вместе с переводом наружу выходят
/api/collections/...,/api/logs,/api/backups,/api/settings,/api/cronsи панель/_/. Правила доступа коллекций оставлены пустыми, то есть доступны они только владельцу панели, — и коллекции, заведённые 2026-08-14, тоже: содержимое записи отдаёт собственный адрес сервиса, а не поверхность хранилища. Коды, снятые прогоном, — database.md, «Коллекции», норма — storage, «Наружу хранилище отдаёт только то, что заказано».
Целевой периметр добавляет сюда три вещи, и все три — от новых задач:
- Хеш-сумма содержимого (
dedup-by-content-hash) становится ключом поиска прежней записи. Ищется она в пределах одного пользователя: глобальный поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и заодно сообщал бы, что запись у кого-то уже есть. - Файлы фрагментов (
long-audio-chunking) ложатся рядом с исходным в тот же плоский каталог — раскладка каталога данных меняется, и это необратимо. - Имя отправляемого документа (
long-text-delivery) собирается из идентификатора задачи: имя, данное пользователем, в него не попадает.
Что разграничивает доступ
- HTTP API — сессия, заведённая входом через OIDC у Authelia. Предъявляется
кукой
transcriber_session, обесценивается выходом, срок жизни назначен числом (database.md, «Настройки с числовым значением»). Продление сессии закрыто: с ним предъявитель менял бы своё значение на новое бессрочно, и назначенный срок — единственное, чем отзыв доступа у провайдера доходит до сервиса, — не значил бы ничего. Предъявленный заголовокAuthorizationпринимается тоже — это та же сессия и та же проверка, но она названа здесь отдельно, потому что это второй способ предъявить ту же сессию. - Файл записи — короткий токен файла, который узнанный отправитель берёт у
хранилища, предъявив сессию. Поле файла помечено защищённым, правило просмотра
коллекции пускает всякого вошедшего, и ссылка
/api/files/...перестала быть правом пройти по ней. Браузер с одной лишь кукой файла не получает: порядок здесь «сессия → токен файла → ссылка». - Кто допущен — решает Authelia, а не сервис. Своей проверки группы приложение не делает: кого пускать, определяет правило провайдера на этого клиента. Правило живёт вне репозитория, в настройках выкладки, и по коду его не проверить. Клиент, настроенный слишком широко, открывает сервис всякому, у кого есть учётная запись в общей Authelia. Решение владельца от 2026-08-12.
- Заведение учётной записи — только входом у провайдера. Собственное создание записи, вход по паролю, одноразовый код и восстановление доступа выключены шагом схемы: хранилище заводит коллекцию пользователей открытой, и без этого закрытия вход обходился бы двумя запросами.
- Метрики и здоровье —
GET /metricsиGET /healthоткрыты без сессии: её нет ни у пробы, ни у сборщика. Наружу их закрывает правило обратного прокси — работа выкладки, и сервис на неё не полагается: содержимого записей эти адреса не несут. - Приложение — его разметка и ресурсы открыты без сессии, и ограничителя частоты на них нет: правило заведено под корень приложения, а раздача стоит вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы для всех и собраны до всякого запроса. По ответу нельзя узнать, вошёл ли кто-то, — вошедшему и не вошедшему отдаётся одно и то же.
Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла есть владелец. Знание идентификатора задачи правом её читать больше не является — читает её тот, кто её принёс.
Целевой периметр заводит четыре механизма вместо одного белого списка; первый из них уже стоит:
| Механизм | Что даёт | Чья задача |
|---|---|---|
| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты — сделано 2026-08-12 | oidc-login |
| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» — сделано 2026-08-14 | record-ownership |
| Личный токен | Права своего владельца программе, без браузерной сессии | api-tokens |
| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | admin-stats-screen |
Признак владельца сервиса — второй уровень доступа, которого в сегодняшней
модели нет вовсе: до него всё разграничение сводилось к «свой или чужой».
Откуда он берётся — из группы OIDC или из конфигурации — не решено
(admin-stats-screen).
Панель администратора в эту таблицу не входит и разграничению не подчиняется.
Суперпользователь PocketBase видит все записи, все файлы и всех пользователей
мимо любого из четырёх механизмов, а пускает его свой пароль, а не Authelia.
Замер показал, что закрыть панель провайдером OIDC или вторым фактором нельзя:
обе настройки у коллекции суперпользователей отклоняются. Остаётся ограничение
по списку адресов (superuserIPs), и оно же запирает владельца, если список
задан неверно: сброса в наборе команд нет.
Что чувствительнее чего
- Содержимое записей и расшифровок. Голосовые сообщения — личная переписка;
это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной
колонкой, а шестью коллекциями: сама запись (заголовок и краткое описание),
texts(расшифровка и вычитанный текст),structures(реплики со временем),recognitions(сырой ответ провайдера вложением — полный текст речи),record_events(журнал событий, содержимого не несёт) иtopics(словарь тем человека). Всякая новая коллекция, куда содержимое переезжает, закрывается наравне с записью — норму держит спекаstorage. - Ключи Yandex Cloud —
speech_kit_api_keyи пара ключей Object Storage. Утечка оплачивается деньгами и доступом к бакету. - Секрет клиента OIDC — вместе с адресами провайдера открывает вход в приложение от чужого имени.
Всё перечисленное лежит в 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 открыт вместе с остальным: без
приведения хвост читал бы кто угодно из интернета, а множеством значений метки
распоряжался бы анонимный отправитель.
Два пути утечки токена бота — адрес Bot API в отказе транспорта и отказ сборки
клиента — закрыты задачами no-user-filename-in-log и
local-run-without-telegram-token 2026-08-13 и потеряли предмет 2026-08-14
вместе с убранным входом: ни клиента, ни токена у сервиса больше нет. Разбор
случая остался в review.md — он про класс, а не про Telegram.
Путь, который остался, закрыт задачей telegram-enabled-flag 2026-08-13, и он
шире всякого одного ключа: до неё утечь мог любой секрет конфига. Отказ разбора файла
настроек пересказывался как есть, а библиотека разбора собирает текст отказа из
разбираемого куска — toml.ParseError кладёт в сообщение само значение. Строка
секретного ключа с оборванной кавычкой — типовая поломка криво собранного
шаблона выкладки — уносила ключ в журнал контейнера целиком. Теперь такой отказ
пересобирается своими словами: путь, строка, столбец и последний ключ, без текста
библиотеки; прочие отказы декодера собраны из имён ключей и типов и потому
проходят как есть. Нашло это ревью дизайна, чинилось решением владельца в той же
работе. Правило — conventions/config.md, «Секреты»;
оракулы — internal/config/config_test.go, проверки поломанного файла настроек.
Остаточный риск назван там же: разрез опирается на то, какое семейство отказов
несёт значения в нынешней версии библиотеки.
Что вне модели
Перечислить явно.
-
Атака на сам сервер и на контур. Компрометация хоста, прокси, Docker и Ansible — не наша граница.
-
Машина разработчика и то, что он на ней поднимает. С 2026-08-15 в репозитории лежит
cmd/oidcstub— подставной провайдер OIDC, который выдаёт сессию всякому спросившему и не сверяет ни секрета клиента, ни проверочного кода PKCE. Двух вещей это не отменяет, и обе проверяемы: в образ он не едет (ступень собирает./cmd/transcriberпоимённо), а слушает петлевой адрес. Периметра выкладки заглушка поэтому не касается; кто поднял её у себя в чужой сети, отвечает за это сам. -
Злоупотребление со стороны пользователя из белого списка. Приглашённому доверяем полностью.
-
Достоверность расшифровки. Подмена или искажение текста на стороне 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 и всеми уровнями текста. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.Руками запись сегодня не удаляется, и прежняя строка об этом была неверна. Проверено прогоном 2026-08-14: содержимое живёт в коллекциях, перечисленных выше («Что чувствительнее чего»), связи приложений с записью обязательны и каскада не имеют, поэтому удаление самой строки записи отвергается хранилищем, а удаление её файлов проходит молча. Владелец, выполнивший прежнюю процедуру, стирает аудио и оставляет полный текст речи — расшифровку, разбивку по репликам и сырой ответ провайдера файлом на диске. Порядок, которым запись убирается на самом деле: сперва строки приложений — журнал событий, попытка распознавания вместе с её вложением, структура, тексты, — потом сама запись, потом её файлы. До
delete-recordэто единственный способ, и он ручной целиком.