diff --git a/docs/lk/report-2026-09-17-security-and-data.md b/docs/lk/report-2026-09-17-security-and-data.md new file mode 100644 index 0000000..6251684 --- /dev/null +++ b/docs/lk/report-2026-09-17-security-and-data.md @@ -0,0 +1,153 @@ +# Кабинет клиента: безопасность, связь без публичного адреса, покрытие данных + +Отчёт на 17.09.2026. Всё перечисленное выкачено на astramost и влито в `main` (коммит 2a7442c). + +## 1. Как кабинет подключается к ядру + +Настройки → «Личный кабинет» → «Кабинет клиента на своём сервере»: + +1. создать ключ доступа (на странице он показан замаскированным, целиком попадает только в архив); +2. выбрать способ связи; +3. скачать архив, распаковать на сервере кабинета, выполнить `sudo ./install.sh`; +4. опубликовать через nginx с сертификатом (пример в README архива). + +Инструкция с шагами — «Инструкции» → «Личный кабинет» (`/admin/help/lk`). + +Два способа связи: + +| Способ | Когда | Что нужно | +|---|---|---| +| Кабинет обращается к ядру (`BJ_MODE=direct`) | у ядра есть адрес, доступный серверу кабинета | адрес ядра в настройках, только https | +| Ядро подключается к кабинету (`BJ_MODE=agent`) | у ядра нет адреса в интернете | адрес кабинета в настройках ядра; входящих портов не нужно | + +Во втором способе кабинет копит запросы клиентов, ядро само дозванивается до кабинета по его +доменному имени (4 параллельных подключения), выполняет запрос тем же обработчиком `/api/lk/*` +и возвращает ответ. Канал в обе стороны закрыт ключом модуля; ядро выполняет только пути +`/api/lk/…`. Состояние канала видно в настройках: подключено ли, время последнего обмена, +число обслуженных запросов. + +## 2. Проверка безопасности + +Аудит нашёл 5 критичных, 12 важных и 9 мелких замечаний. Исправлено: + +**Критичные** +- перебор пароля без ограничений → лимит по адресу посетителя (10) и по логину (8) на 15 минут, + одинаковая задержка ответа; проверено вживую: блокировка на 8-й попытке; +- живой токен сотрудника лежал в куке, читаемой скриптами → все куки `HttpOnly`; +- CSRF отсутствовал, `/impersonate` срабатывал по ссылке → токен на всех формах, вход сотрудника + в кабинет клиента только POST, `SameSite=Strict`; +- куки без `Secure` → ставится всегда, кроме обращения на localhost; +- сессии и паспортные данные клиентов лежали на диске ядра открытым текстом → в файле теперь + отпечаток токена (sha256) и ссылка на учётную запись, персональных данных нет. + +**Важные** +- выход гасит все сессии, включая сохранённую сессию сотрудника; возврат из кабинета клиента + гасит сессию просмотра в ядре; +- карточка клиента читается из БД на каждый запрос: блокировка и снятие прав действуют сразу; +- абсолютный срок сессии 24 ч (скользящий — 12 ч), привязка к браузеру; привязка к сети — + настройкой, по умолчанию выключена (мобильных клиентов иначе выбрасывает); +- вход сотрудника в кабинет клиента пишется в `lk.audit` и поднимает оповещение; +- заголовки CSP / X-Frame-Options / nosniff / Referrer-Policy / HSTS, запрет кэширования страниц; +- таймауты сервера кабинета, ограничение тела запроса 1 МБ, перехват паники; +- предупреждение в журнале, если адрес ядра задан по открытому http; +- ключ модуля и токен сессии: при отказе источника случайных чисел — ошибка, а не суррогат; +- ключ на странице настроек замаскирован. + +**Мелкие** +- одинаковое время ответа для несуществующего логина (защита от перечисления логинов); +- пароль от 10 символов, буквы + цифра или знак; +- сообщения и ошибки больше не едут в адресе страницы (не попадают в журнал nginx); +- шрифты вшиты в программу: страница не обращается ни к одному стороннему сайту; +- экранирование `% _ [` в выборках по номеру договора; +- `/health` без ключа отдаёт только `{"ok":true}`. + +Заодно найдена и исправлена ошибка: текст «неверный логин или пароль» клиенту не показывался +вовсе (шаблон ждал поле `Error`, обработчик передавал `Flash`). + +**Осталось осознанно** +- боевой ключ лежит внутри скачиваемого архива — иначе установка не одной командой. В README и + на странице настроек стоит предупреждение удалить архив после установки. Вариант на будущее — + одноразовый код привязки вместо ключа; +- список клиентов у сотрудника ищется через `GET /clients?search=…` — ФИО попадает в журнал + nginx сервера кабинета. Лечится настройкой nginx (не писать query) либо переводом поиска на POST; +- файл сессий переписывается целиком при входе/выходе — при тысячах одновременных сессий стоит + перейти на отложенную запись. + +**Что уже было сделано правильно:** bcrypt, выборка данных строго по договору из серверной сессии +(подменой запроса чужие данные не получить), право сотрудника проверяется в ядре, сравнение ключа +за постоянное время, параметризованный SQL, `html/template` без небезопасных вставок, поручения +сверяются с фактическим остатком, кабинет ничего не хранит на диске. + +### Тесты + +| Набор | Что проверяет | Результат | +|---|---|---| +| `go test ./...` | весь проект | проходит | +| `go test -race ./modules/lk ./internal/lkgateway` | гонки в очереди режима ожидания и сессиях | проходит | +| `lksecurity_test.go` | файл сессий без токена и ПДн, права 0600, привязка к устройству, абсолютный срок, только просмотр у сотрудника, API без ключа/с чужим ключом/без сессии, перебор, пароли, LIKE | 6 из 6 | +| `lkagent_test.go` | настоящий кабинет + настоящий цикл ядра; чужой путь через канал не выполняется | 2 из 2 | +| `modules/lk` | CSRF, заголовки, запрет GET `/impersonate`, режим ожидания, страницы | проходит | +| `scripts/lk-sec-smoke.sh` | работающий кабинет снаружи: заголовки, куки, страницы без входа, формы без токена, обход каталога, большое тело, поддельные куки | 34 из 34 | +| живые (`-tags live`, только чтение) | история, цены, карточка бумаги, сверка с DEPO, покрытие данных | проходит | + +Запуск проверки снаружи: `scripts/lk-sec-smoke.sh https://адрес-кабинета`. + +## 3. Какие данные кабинет показывает сейчас + +Выборка — 60 реальных договоров, операции и доходы за 2 года: + +| Раздел | Источник | Данные есть | +|---|---|---| +| Портфель (остатки) | DEPO `OD_RESTS` | 60 из 60 клиентов, 143 позиции | +| Оценка по рынку | экспорт QUIK `Securities`+`Params` | 77 из 143 позиций (54 %) | +| Операции по счёту депо | DEPO `U_OP_INV_*` | 58 из 60, 1278 строк | +| Доходы и налог | DEPO `U_OP_INF_INCOME`, `OD_NDFL` | 26 из 60, 350 строк | +| Корпоративные действия | DEPO `U_CA_ACTS` | 2 из 60 | +| Готовые выписки | DEPO `U_OP_INF_EXTRACT` | 10 из 60, 29 документов | + +Почему оценено только 54 % позиций — это не ошибка запроса: +- 31 позиция — иностранные бумаги (ISIN US/XS/GB/GG), на Мосбирже не торгуются; +- 35 позиций — внебиржевые выпуски классов `FONT_OUT` / `FONT_OUT2` (в основном бумаги группы + Гарант-Инвест), рыночной цены у них нет. + +Предложение: для облигаций без рыночной цены показывать оценку по номиналу с пометкой «по номиналу», +для иностранных — строку без оценки с пояснением. Решение о способе оценки — за депозитарием. + +## 4. Что показывают кабинеты брокеров и что из этого нам уже доступно + +В экспорте QUIK лежат данные, которые кабинет пока не использует. Код клиента QUIK сопоставляется +с договором напрямую: `BR746` ↔ `БР-746`, `BRIIS514` ↔ ИИС, суффикс `OUT` — внебиржевой раздел. + +| Что обычно есть у брокеров | Есть ли у нас данные | Источник | Оценка работы | +|---|---|---|---| +| Денежные средства по валютам | **да**: рубли у 998 клиентов, юани у 18, золото у 6 | `MoneyLimits` | малая | +| Средняя цена покупки, прибыль/убыток по позиции | **да**: заполнено у 2439 из 2528 позиций (96 %) | `DepoLimits.AwgPositionPrice` | малая | +| Стоимость портфеля одной цифрой | **да**: у 1104 клиентов | `Portfolio.PortfolioValue` | малая | +| Структура портфеля (акции/облигации/фонды, валюты, эмитенты) | **да**, считается из уже загруженного | позиции + цены + CFI | малая | +| Календарь выплат (купоны, оферты, погашения по своим бумагам) | **да** | DEPO `OD_COUPONS`, `OD_OFFERS` | малая | +| Сделки за день | **да**: 1692 сделки, 87 клиентов | `Trades` | малая | +| Активные заявки | **да**: 1323 заявки, 94 клиента | `Orders` | малая | +| История сделок за период | **частично**: в экспорте только текущий день | `Trades` + накопление, как сделано для НПР | средняя | +| График стоимости портфеля во времени | **нет истории**: нужно начать копить ежедневный срез | новая таблица срезов | средняя | +| Маржинальные показатели (НПР1/НПР2, УДС, плечо) | **да** | `Portfolio`, схема `npr` | малая | +| Признак ИИС, статус квалифицированного инвестора | **да** | префикс договора, DEPO `IS_QUAL` | малая | +| Налоги: удержанный НДФЛ | **уже показываем** | DEPO `OD_NDFL` | — | +| Справки и отчёты | **уже показываем** + заявка на документ | DEPO, `lk.doc_requests` | — | +| Доходность в процентах годовых | **нет**: нужны вводы/выводы денег за период | SPECTRE или накопление | большая | +| Ввод/вывод денег, пополнение | нет — это операции брокера, не депозитария | — | вне модуля | +| Торговля из кабинета | нет — для этого есть QUIK | — | вне модуля | + +Рекомендуемый порядок: деньги → средняя цена и прибыль/убыток → структура портфеля → +календарь выплат → сделки за день. Всё это — малые работы на уже включённых пакетах экспорта. +Для денег и сделок нужно решение: показывать ли брокерские данные в кабинете депозитария +(технически готово, вопрос в продукте и в том, какие клиенты это должны видеть). + +## 5. На что посмотреть завтра + +- настройки → «Личный кабинет»: новый блок связи, замаскированный ключ, кнопка архива; +- кабинет `http://…:8099`: вход (теперь с сообщением об ошибке), «Документы», блок + корпоративных действий на «Доходах»; +- после выкатки все клиентские сессии сброшены (сменился формат хранения) — войти заново; +- пароли короче 10 символов завести больше нельзя; существующие учётные записи продолжают работать; +- в корне репозитория лежит старый `s.html` (сохранённая страница настроек от 14.08) — в коммит + не включён; в таких страницах бывают ключи, файл стоит удалить.