Files
transcriber/docs/security.md
T
av 31ce520c5b документы: сведены расхождения, найденные сверкой канона
- один факт — один дом: рецепт локального входа, правило чтения
  X-Forwarded-For, уровень строки журнала и опись опор изъятия сведены к
  своим домам, копии заменены ссылками
- форма [auth.test_headers] выровнена по образцу конфига в семи местах;
  сценарии intake и archive перестали ссылаться на сессию, которой сервис
  не выдаёт
- поправлены протухшие факты: ключ объекта строит ULID, а не UUID; сверку
  адреса пира зовут трое, а не двое; обзор capability access знает о
  задаче 2026-08-23
2026-08-23 16:41:06 +03:00

48 KiB
Raw Blame History

Модель угроз

Периметр

Сервис открыт наружу, но не анонимен: HTTP-порт опубликован в интернет через обратный прокси, а приём записи, чтение её карточки и текста и файл записи требуют, чтобы пришедшего назвала Authelia. С 2026-08-22, задачей trusted-header-login, называет она его заголовком, который ставит обратный прокси: своего входа у сервиса не осталось — ни адреса к провайдеру, ни возврата, ни куки, ни выхода. Прежде сервис вёл вход сам (oidc-login 2026-08-12) и потом семь суток верил выданной куке; теперь Authelia судит каждый запрос, и отзыв доступа действует со следующего.

Без узнавания открыты проба здоровья, метрики и — с 2026-08-15, задачей spa-skeletonсамо приложение: его разметка и её ресурсы, а вместе с ними всякий путь, не принадлежащий ни одному корню сервиса. Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал, до приложения дошёл бы только мимо него — а закрытая разметка выглядела бы поломкой сервиса, а не отказом входа. Данных открытость не касается — всякий адрес под корнем приложения узнанного по-прежнему требует. Находки строятся против этого — сегодняшнего — периметра.

Состав того, что отдаётся анонимно, задаёт содержимое собранного приложения, а каталог его лежит в .gitignore и не судится ничем: всё, что окажется там у собирающего, уезжает в бинарник и раздаётся. Под каталогом ресурсов оно ещё и отдаётся с годовым сроком хранения и пометкой «неизменяемо» — отозвать выданное браузеру сервису нечем.

Целевой периметр добавляет к нему отдельный вход для программ по личным токенам и два уровня доступа — пользователь видит свои записи, владелец сервиса ещё и страницу расхода. Разграничение по владельцу записи заведено 2026-08-14 задачей record-ownership: и чтение записи, и файл записи сужены владельцем записи, а чужая отвечает «не найдено». Целевому периметру недостаёт теперь второго уровня доступа — страницы расхода для владельца сервиса.

Ничьих записей у сервиса больше не бывает: колонка владельца пустого значения не принимает, и держит это схема хранилища. Прежде такие записи заводил вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход убран вместе с этим исключением.

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

Третьего сдвига — панели администратора — больше нет, и это снятие. Решением от 2026-08-11 хранилищем становилась PocketBase, и вместе с ней на том же порту появлялась панель /_/: доступ ко всем записям, всем файлам и всем пользователям разом, закрываемый не приложением, а правилом обратного прокси. 2026-08-22, задачей storage-without-pocketbase, встроенное хранилище убрано целиком: панели не существует, второго периметра на порту сервиса не осталось, и правилу прокси нечего закрывать.

Вместе с панелью снят и дефект подменённого знака. Маршрутизатор сравнивал сегменты пути после раскодирования, поэтому /%5f/ попадал в ту же группу, что и /_/, а правило прокси, написанное на литерал, такой формы не видело — весь клиент панели грузился анониму (проверено прогоном 2026-08-15 ревью задачи spa-skeleton). Лечится он теперь тем, что за обоими адресами не стоит ничего: оба попадают под общее правило неизвестного пути и отдают разметку приложения. Проверено прогоном 2026-08-22: /_/, /%5f/ и всякий путь под /api/ отвечают байт в байт тем же, чем отвечает выдуманный путь вне корней сервиса.

Четвёртый сдвиг был — секрет клиента в базе, — и он снят. Задача oidc-login 2026-08-12 клала адреса провайдера, идентификатор клиента и его секрет в настройки коллекции пользователей, и чтение файла базы становилось равносильно чтению секрета. 2026-08-22 секрета не стало вовсе: обменивать код не на что, и изъятие из инварианта «Секрет не покидает конфиг» снято вместе с ним.

Вместо него — новый и главный: барьер держится на том, что прокси ставит заголовок сам. Сервис верит Remote-User, пришедшему с адреса из объявленного перечня, а перечень этот и есть адрес прокси. Прокси, настроенный добавлять заголовок вместо замены, оставит рядом со своим значением присланное анонимом — и аноним войдёт под любым именем. Часть этой беды сервис закрывает сам: запрос с двумя значениями Remote-User не узнаёт никого. Закрыт при этом только логин. Remote-Name и Remote-Email берутся первым значением, то есть присланным анонимом, и адрес почты, занятый им, закрепляется за чужой учётной записью навсегда: колонка уникальна, а найденную запись узнавание не переписывает. Прогон ревью 2026-08-23 построил этот путь и прогнал его; правило решено распространить на всю тройку отдельной задачей. Остальное проверить отсюда нечем: правило живёт в files/caddyproxy/Caddyfile.template репозитория pet-project-server, и требование к нему такое — заголовки Remote-* прокси обязан перезаписывать, а не пропускать. Выкладку запускает человек.

Изъятие из барьера одно — отладочный запуск, и заведено оно 2026-08-23 задачей config-test-headers-login. При включённом предохранителе [server] debug заголовки входа ставит не прокси, а сам сервис значениями из секции [auth.test_headers]: на машине разработчика прокси нет, а браузер заголовков не ставит. Узнавание при этом остаётся тем же и подставленного заголовка от пришедшего не отличает — отлаживается боевая ветка. Нормирует изъятие спека access, решение о подстановке самим сервисом — ADR-2026-08-23-test-headers-substituted-by-service.

Держится оно тремя вещами, и других нет: умолчание предохранителя — «выключено»; заполненная имитация при выключенном предохранителе роняет старт с именем ключа; боевой конфиг рендерится шаблоном Ansible, а не копируется с машины разработчика. Подставленный заголовок проходит тот же барьер доверенного адреса, что и пришедший, и судит адрес та же функция — но барьером отладочному входу это не служит: перечень доверенных адресов включению предохранителя не мешает.

Боевая поломка машиной не исключена, и это названо прямо. Сервис, поднятый в бою с включённым предохранителем и заполненной имитацией, поднимется на любом перечне доверенных адресов и назовёт своим именем всякого, чей запрос пришёл через обратный прокси, — то есть всякого, кто пришёл обычным путём. Адресного предохранителя у изъятия нет: требование петлевого перечня рассматривалось и снято — ADR-2026-08-23-no-address-guard-for-debug-login.

X-Forwarded-For сервис читает сам, и правило чтения закрывает дописывание. Как именно читается цепочка, нормирует спека archive, «Адреса приложения живут своим пространством». Отсюда периметровое следствие: прокси, дописывающий X-Forwarded-For к присланному, этим правилом покрыт, и требования «перезаписывать, а не дописывать» у сервиса к нему нет — в отличие от Remote-*. Барьером узнавания заголовок при этом не служит: кто пришёл, решает адрес самого соединения.

Ширина перечня доверенных адресов — тоже цена, и она принимается сознательно. Перечень задаёт, чьему Remote-User верить, и всякий, кто дотянулся до сервиса с такого адреса, называет себя кем угодно. Перечень поэтому обязан покрывать адрес прокси, а не весь частный диапазон: сеть докера целиком означает «любой контейнер на хосте», включая чужие. Образец конфига называет узкий пример именно поэтому.

Пятый сдвиг — логин у провайдера переиспользуем. Ключ учётной записи — Remote-User, то есть логин человека у Authelia. Логин можно выдать заново после ухода прежнего владельца, и тогда новый человек при первом же обращении попадает в существующую запись и получает весь её архив — самое чувствительное, что у сервиса есть. Сервис этого не различает и различить не может: неизменяемого признака заголовок не приносит. Не допускать переиспользования — работа провайдера, и это принятая цена, записанная в access. Обратная сторона той же цены: переименование заводит новую запись, а прежняя остаётся с архивом, который нечем ни слить, ни убрать.

Отсюда главное следствие, из которого читается всё остальное: POST /app/audiorecords требует входа, а размер файла ограничен потолком записи, число же запросов ограничено только частотой. Вошедший тратит наши деньги на распознавание столько, сколько захочет: ограничитель частоты под корнем приложения заведён 2026-08-15 и режет темп, а не общий объём. Квоты по объёму по-прежнему нет — её заводит per-user-size-quota.

Недоверенный вход

Что приходит извне и каким каналом.

Вход Канал Кто может слать
Имя пришедшего, имя для показа и почта Заголовки Remote-User, Remote-Name, Remote-Email Обратный прокси — и всякий, кто дотянулся до сервиса с доверенного адреса. Значение принимается: пустое, пробельное, длиннее 255 знаков и с управляющими знаками не узнают никого; два значения одного заголовка не узнают никого тоже. С недоверенного адреса заголовок не действует, и это идёт в журнал предупреждением с адресом пира, но без значения. Слать тройку может ещё и сам сервис — при включённом предохранителе [server] debug, значением из настроек; изъятие целиком описано в «Периметре» выше
Аудиофайл и его имя POST /app/audiorecords, multipart-поле audio Любой узнанный; неузнанному — 401 до чтения тела. Имя доходит до колонки записи обрезанным по пределу и без управляющих знаков
Идентификатор записи GET /app/audiorecords/{id} и /text Любой узнанный; неузнанному — 401, одинаковый для заведённой и незаведённой записи
Ключ страницы, размер страницы, состояние отбора GET /app/audiorecords, параметры запроса Любой узнанный; нечитаемый ключ и негодный размер дают 400, а не молчаливую первую страницу
Вид текста GET /app/audiorecords/{id}/text, параметр view Любой узнанный; значение вне закрытого перечня даёт 400
Содержимое аудио Файл, скармливаемый ffmpeg и ffprobe Отправитель
Текст расшифровки Поток gRPC от SpeechKit Yandex, а через него — содержимое записи

Что добавится вместе с целевым периметром — каждый вход появляется своей задачей, и до неё его нет:

Вход Канал Кто может слать Чья задача
Токен доступа Заголовок запроса к /api/ Любой из интернета api-tokens
Заголовок, темы, пересказ Ответ языковой модели Внешняя модель, а через неё — содержимое записи 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/records/<ULID записи>/<имя>. Обе части задаёт он сам — подкаталог назван идентификатором записи, имя файла это <ULID><расширение>, — и имя, данное отправителем, не попадает ни в одну из них. Расширение берётся из имени отправителя через filepath.Ext без проверки списком; filepath.Ext режет по последней точке и не пропускает разделитель каталогов, но это единственное, что стоит между входом и именем файла. Длина расширения при этом ограничена числом — иначе x. с четырьмястами знаками роняет заведение временного файла.
  • Ключ объекта в Object Storage — то же имя файла, то есть UUID с расширением. Бакет один на все записи, префикса по пользователю нет. С 2026-08-14 копия там файлом записи не считается: она существует лишь потому, что провайдер читает аудио по адресу, и её ключ живёт в строке попытки распознавания.
  • Сохранённый ответ провайдера лежит третьим файлом в том же подкаталоге записи, под именем, которое задаёт сервис. Содержимое там — полный текст речи, а не метаданные, поэтому закрыт он наравне с расшифровкой: адреса, которым его читают снаружи, у сервиса нет вовсе, а путь к нему не пишется ни в журнал, ни в метку метрики, ни в ответ.
  • Адрес файлаGET /app/audiorecords/{id}/file?copy=original|normalized. Право пройти по нему даёт узнавание пришедшего и владение записью, и судится оно там же, где отдаётся файл. Значений на предъявителя сервис не выдаёт вовсе: короткий токен файла ушёл 2026-08-22 вместе со встроенным хранилищем, и отзыв доступа доходит до файла сразу, а не через срок жизни выданного значения. Запрет при этом остаётся: имя файла на диске в журнал не пишется — строка журнала стала бы бессрочным ключом к чужой записи. В журнал идёт расширение своим полем.
  • Идентификатор записи — ULID, 26 знаков, выдаёт приложение. Он же единственное, что защищает карточку записи, её текст и её файл сверх владения.
  • Чужой поверхности на порту сервиса нет. Адреса /api/collections/..., /api/logs, /api/backups, /api/settings, /api/crons и панель /_/ ушли вместе со встроенным хранилищем 2026-08-22. Отвечает сервис только своими адресами, а всё прочее идёт общим правилом неизвестного пути — норму держит webapp. Что содержимое записи закрыто везде, где лежит, нормирует storage.

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

  • Хеш-сумма содержимого (dedup-by-content-hash) становится ключом поиска прежней записи. Ищется она в пределах одного пользователя: глобальный поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и заодно сообщал бы, что запись у кого-то уже есть.
  • Файлы фрагментов (long-audio-chunking) ложатся рядом с исходным в тот же плоский каталог — раскладка каталога данных меняется, и это необратимо.
  • Имя отправляемого документа (long-text-delivery) собирается из идентификатора задачи: имя, данное пользователем, в него не попадает.

Что разграничивает доступ

  • HTTP API — заголовок Remote-User, пришедший с адреса из объявленного перечня доверенных. Адрес берётся у самого соединения, а не из пересылаемого заголовка: пересылаемым распоряжается тот, кто шлёт запрос. Значения, переживающего запрос, сервис не выдаёт вовсе — ни куки, ни токена, — и потому отзыв доступа у Authelia действует со следующего обращения. Собственных токенов сервис не принимает вовсе: значения, предъявленного запросом и дающего доступ помимо заголовка, у него не существует. Прежде такое значение било заголовок — им работал владелец панели; панели нет, и правило приоритета осталось бы правилом без предмета. Область узнавания — корень приложения, и выводится она из объявленного адресного пространства сервиса: слои одеты на корень целиком, вторым списком адресов область не описывается. Проба здоровья, метрики и ресурсы приложения под неё не подпадают — иначе запрос за каждой картинкой стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё.
  • Учётная запись — заводится первым обращением с новым логином и находится по нему же дальше. Ключ — колонка provider_login, уникальная; править её снаружи нельзя, потому что адреса правки учётной записи у сервиса нет вовсе: своих экранов профиля он не заводит, а поверхности хранилища, правившей запись библиотечным правилом, не осталось.
  • Файл записи — узнавание пришедшего и владение записью, судимые в самом обработчике отдачи. Отказ наступает на обращении за файлом: другого места, где он мог бы наступить, у сервиса не осталось. Значений, переживающих запрос, сервис не выдаёт ни одного, поэтому отзыв доступа доходит и до файла.
  • Кто допущенрешает Authelia, а не сервис. Своей проверки группы приложение не делает: кого пускать, определяет правило провайдера на этого клиента. Правило живёт вне репозитория, в настройках выкладки, и по коду его не проверить. Клиент, настроенный слишком широко, открывает сервис всякому, у кого есть учётная запись в общей Authelia. Решение владельца от 2026-08-12.
  • Собственного входа у сервиса нет вовсе. Создание записи, вход по паролю, одноразовый код, обмен кода у внешнего провайдера, восстановление доступа и продление принадлежали встроенному хранилищу и ушли вместе с ним: закрывать больше нечего, и адресов этих не существует.
  • Метрики и здоровьеGET /metrics и GET /health открыты неузнанному: учётной записи нет ни у пробы, ни у сборщика. Заголовок их ответа не меняет и учётной записи на них не заводит. Наружу их закрывает правило обратного прокси — работа выкладки, и сервис на неё не полагается: содержимого записей эти адреса не несут.
  • Приложение — его разметка и ресурсы открыты неузнанному, и ограничителя частоты на них нет: правило заведено под корень приложения, а раздача стоит вне его. Содержимого записей ни разметка, ни ресурсы не несут: они одинаковы для всех и собраны до всякого запроса. По ответу нельзя узнать, узнан ли кто-то, — узнанному и неузнанному отдаётся одно и то же.

Владение записью в модели данных появилось 2026-08-14: у задачи и у её файла есть владелец. Знание идентификатора задачи правом её читать больше не является — читает её тот, кто её принёс.

Целевой периметр заводит четыре механизма вместо одного белого списка; первый из них уже стоит:

Механизм Что даёт Чья задача
Заголовок от Authelia через прокси Право открыть приложение и его эндпоинты — сделано 2026-08-22; прежде то же давала сессия OIDC, с 2026-08-12 trusted-header-login
Владелец у задачи и файла Чужая запись по её идентификатору отвечает «не найдено» — сделано 2026-08-14 record-ownership
Личный токен Права своего владельца программе, которой прокси заголовка не ставит api-tokens
Признак владельца сервиса Страницу расхода и сводку по всем пользователям admin-stats-screen

Признак владельца сервиса — второй уровень доступа, которого в сегодняшней модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». Откуда он берётся — из группы OIDC или из конфигурации — не решено (admin-stats-screen).

Панели администратора в этой таблице нет, и это снятие, а не пропуск. До 2026-08-22 суперпользователь встроенного хранилища видел все записи, все файлы и всех пользователей мимо любого из механизмов разграничения, а пускал его свой пароль, а не Authelia. Хранилище ушло, панели не существует, и разграничение у сервиса осталось одно — владение записью.

Владелец сервиса взамен получил одно действие и один инструмент: подкоманда cmd/devtools resume возвращает остановленную запись в работу. Она ходит в тот же каталог данных, то есть требует доступа к файлам сервера, а не к сети: поверхности, открытой в интернет, у неё нет вовсе.

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

  1. Содержимое записей и расшифровок. Голосовые сообщения — личная переписка; это самое чувствительное, что здесь есть. С 2026-08-14 оно живёт не одной колонкой, а шестью таблицами: сама запись (заголовок и краткое описание), texts (расшифровка и вычитанный текст), structures (реплики со временем), recognitions (попытка распознавания; сохранённый ответ провайдера — полный текст речи — лежит файлом в подкаталоге записи), record_events (журнал событий, содержимого не несёт) и topics (словарь тем человека). Всякая новая таблица, куда содержимое переезжает, закрывается наравне с записью — норму держит спека storage.
  2. Ключи Yandex Cloudspeech_kit_api_key и пара ключей Object Storage. Утечка оплачивается деньгами и доступом к бакету. Секрета клиента OIDC в этом списке больше нет: 2026-08-22 он ушёл из конфига и из базы вместе с собственным входом.

Всё перечисленное лежит в 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). Текста записей не содержит, но говорит, кто и когда пользовался сервисом и сколько; страница расхода открыта только владельцу. Пароля владельца от панели в этом списке больше нет: он ушёл 2026-08-22 вместе с самой панелью. Секрет, появившийся только ради перевода на встроенное хранилище, пропал, и ключа под него в конфигурации не заводится по той простой причине, что заводить нечего.

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

Два пути утечки токена бота — адрес 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 — не наша граница.

  • Машина разработчика и то, что он на ней поднимает. На место контура встаёт сам сервис: при включённом предохранителе [server] debug он подставляет заголовки входа значениями из конфига. Прежде эту роль играли отдельные процессы — cmd/oidcstub с 2026-08-15 по 2026-08-22 и подкоманда cmd/devtools proxy с 2026-08-22 по 2026-08-23; ни того, ни другой в репозитории больше нет. Периметра выкладки отладочный запуск не касается, пока предохранитель выключен, а выключен он по умолчанию; кто включил его у себя в чужой сети, отвечает за это сам. В оснастке cmd/devtools осталась одна подкоманда — resume, — и в образ она не едет: ступень собирает ./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 и всеми уровнями текста. Учёт расхода удалению не подлежит по решению человека: деньги потрачены, а строки потребления текста не содержат.

    Руками запись сегодня убирается только запросом к базе, и порядок в нём несущий. Содержимое живёт в таблицах, перечисленных выше («Что чувствительнее чего»), связи приложений с записью обязательны и каскада не имеют, поэтому удаление самой строки отвергается базой, пока живы приложения. Порядок такой: сперва строки приложений — журнал событий, попытка распознавания, структура, тексты, связи с темами, — потом сама запись, потом её файлы. Файлы при этом убираются одним движением: подкаталог записи под её идентификатором. Тот, кто убрал только файлы, стирает аудио и оставляет полный текст речи — расшифровку, разбивку по репликам и сохранённый ответ провайдера. До delete-record это единственный способ, и он ручной целиком.