From a5884f1fcd0e65b828aefaa56b2485057bb3d7a4 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Tue, 11 Aug 2026 10:39:08 +0300 Subject: [PATCH] =?UTF-8?q?security:=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20=D1=83=D0=B3=D1=80=D0=BE=D0=B7=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=20?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B8=D0=B2=D1=88=D0=B8?= =?UTF-8?q?=D0=B9=D1=81=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - заведён раздел «Куда уходит содержимое записи»: к Object Storage, SpeechKit и Telegram добавляются языковая модель за bifrost, канал уведомлений и почта; - целевое разграничение доступа описано четырьмя механизмами вместо белого списка: сессия OIDC, владелец записи, личный токен, признак владельца сервиса; токен — первый секрет, который живёт в базе, а не в конфиге; - исправлено неверное утверждение про логи: имя файла отправителя пишется строкой transcribe.go:107, чинит это задача no-user-filename-in-log; - бессрочное хранение и отсутствие квот записаны в «Что вне модели» как следствие решения паспорта, а не как недосмотр. --- docs/security.md | 115 ++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 7 deletions(-) diff --git a/docs/security.md b/docs/security.md index f0e17be..f6dfd7e 100644 --- a/docs/security.md +++ b/docs/security.md @@ -6,9 +6,16 @@ аутентификации не делает ни прокси, ни само приложение.** Находки строятся против этого — сегодняшнего — периметра. -Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, и каждый -пользователь видит только свои записи. Он **не** развёрнут; описанное ниже -разграничение доступа относится только к Telegram. +Целевой периметр: те же порты наружу, но вход через OIDC у Authelia, отдельный +вход для программ по личным токенам, два уровня доступа — пользователь видит +свои записи, владелец сервиса ещё и страницу расхода. Он **не** развёрнут; +описанное ниже разграничение доступа относится только к Telegram. + +**Целевой периметр шире сегодняшнего не только входом.** Содержимое записи +начинает уходить на три новые стороны — языковой модели, в канал уведомлений и +на почту. Записи и тексты хранятся бессрочно: решение паспорта от 2026-08-11 +сделало сервис архивом. Оба сдвига описаны ниже разделами «Куда +уходит содержимое записи» и «Что вне модели». Отсюда главное следствие, из которого читается всё остальное: **`POST /api/audio` доступен кому угодно из интернета**. Отправитель не назван, не ограничен по числу @@ -27,6 +34,40 @@ | Содержимое аудио | Файл, скармливаемый `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` | + +Каждая из названных задач обязана оставить строку в этом разделе — там это +записано их «Затрагивает». Отказ любой из трёх сторон задачу не роняет: текст +остаётся в приложении. + ## Из чего строятся пути и ключи Раскладка файлов на диске, состав пути к файлу и ключа объекта, имя каталога. @@ -44,6 +85,17 @@ - **Идентификатор задачи** — UUID v4. Он же единственное, что защищает `GET /api/status/:id`. +Целевой периметр добавляет сюда три вещи, и все три — от новых задач: + +- **Хеш-сумма содержимого** (`dedup-by-content-hash`) становится ключом поиска + прежней записи. Ищется она **в пределах одного пользователя**: глобальный + поиск отдавал бы чужую расшифровку тому, кто угадал или добыл тот же файл, и + заодно сообщал бы, что запись у кого-то уже есть. +- **Файлы фрагментов** (`long-audio-chunking`) ложатся рядом с исходным в тот же + плоский каталог — раскладка `data/files` меняется, и это необратимо. +- **Имя отправляемого документа** (`long-text-delivery`) собирается из + идентификатора задачи: имя, данное пользователем, в него не попадает. + ## Что разграничивает доступ - **Telegram** — белый список `[server] users_while_list`. Сверяется со строкой @@ -57,6 +109,23 @@ Владения записью в модели данных нет: у задачи нет пользователя. Пока API анонимен, знание UUID задачи и есть право её читать. +Целевой периметр заводит четыре механизма вместо одного белого списка: + +| Механизм | Что даёт | Чья задача | +| --- | --- | --- | +| Сессия OIDC у Authelia | Право открыть приложение и его эндпоинты | `oidc-login` | +| Владелец у задачи и файла | Чужая запись по её идентификатору отвечает «не найдено» | `record-ownership` | +| Личный токен | Права своего владельца программе, без браузерной сессии | `api-tokens` | +| Признак владельца сервиса | Страницу расхода и сводку по всем пользователям | `admin-stats-screen` | + +Белый список Telegram при этом перестаёт быть отдельным механизмом: право +писать боту выводится из учётной записи (`telegram-account-link`). + +Признак владельца сервиса — **второй уровень доступа**, которого в сегодняшней +модели нет вовсе: до него всё разграничение сводилось к «свой или чужой». +Откуда он берётся — из группы OIDC или из конфигурации — не решено +(`admin-stats-screen`). + ## Что чувствительнее чего 1. **Содержимое записей и расшифровок.** Голосовые сообщения — личная переписка; @@ -69,9 +138,31 @@ Всё перечисленное лежит в `config.toml`. Файл в `.gitignore`, на сервер его кладёт Ansible; `gitleaks` на pre-commit смотрит только индекс коммита. -Тексты расшифровок и имена файлов в логи не пишутся — логируется длина текста и -идентификаторы. Токен бота попадает в URL скачивания файла (`file.Link(token)`), -и этот URL нигде не логируется. +Целевой периметр добавляет к списку пять записей, и первая из них — новый вид +секрета, которого сегодня в проекте нет вовсе: + +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` — первая строка очереди. + +Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL +нигде не логируется. ## Что вне модели @@ -85,7 +176,17 @@ SpeechKit не рассматривается. - **Стойкость к целенаправленной нагрузке.** Ограничения по числу запросов и по размеру файла нет, и защищаться от исчерпания диска мы сейчас не пытаемся. +- **Исчерпание диска приглашёнными.** Записи и тексты хранятся бессрочно + (паспорт, 2026-08-11), шестичасовая запись весит гигабайты, а квот нет и не + будет: решено считать расход и показывать его владельцу, а не отказывать + (цель `usage-stats`). Перебравшего останавливает разговор или отзыв доступа в + Authelia. Рост каталога `data/files` при этом ничем не наблюдается — + открытый вопрос `architecture.md`. +- **Перерасход денег на внешних сервисах.** Распознавание и языковая модель + оплачиваются по факту; потолка на пользователя нет по тому же решению. - **Стойкость `ffmpeg` к вредоносному входу.** Разбор чужого формата отдан внешней программе, своей песочницы вокруг неё нет. - **Удаление данных по требованию.** Ни файлы, ни расшифровки не удаляются - вовсе; забвение не реализовано и в задачах не стоит. + вовсе, и удаление по требованию в задачах не стоит. С 2026-08-11 это уже не + недосмотр, а следствие решения хранить бессрочно — но требование «убери мою + запись» им не закрыто, и задачи под него нет.