Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита

Данные
- корпоративные действия по бумагам клиента (U_CA_ACTS), готовые выписки (U_OP_INF_EXTRACT),
  заявки клиента на документ (lk.doc_requests) с оповещением сотрудникам; страница «Документы»
- дистрибутив кабинета из настроек ядра: архив с программой, службой, настройками,
  установщиком и инструкцией; ключ доступа на странице показан замаскированным

Связь без публичного адреса у ядра
- режим ожидания (BJ_MODE=agent): кабинет копит запросы, ядро само подключается к нему,
  выполняет их обработчиком /api/lk/* и возвращает ответы; 4 параллельных подключения,
  тот же ключ модуля в обе стороны, состояние канала в настройках
- сквозной тест: настоящий кабинет + настоящий цикл ядра

Защита (по итогам аудита)
- вход: лимит попыток по адресу и логину, одинаковое время ответа для несуществующего логина,
  пароль от 10 символов
- сессии ядра: на диске отпечаток токена и ссылка на учётную запись, без персональных данных;
  карточка клиента читается из БД на каждый запрос (блокировка действует сразу); абсолютный
  срок 24 ч; привязка к браузеру, к сети — по настройке
- вход сотрудника в кабинет клиента пишется в lk.audit и поднимает оповещение
- кабинет: CSRF на всех формах, /impersonate только POST, куки HttpOnly+Secure+SameSite=Strict,
  выход гасит все сессии, заголовки CSP/X-Frame-Options/nosniff/HSTS, no-store, таймауты сервера,
  ограничение тела, recover; сообщения через куку вместо адреса; /health наружу без подробностей
- шрифты вшиты в программу: страница не обращается к сторонним сайтам
- экранирование LIKE в выборках по номеру договора; ключ и токен без суррогата при отказе rand
- исправлено: ошибка входа не показывалась клиенту (шаблон ждал Error, приходил Flash)

Тесты: lksecurity_test, lkagent_test, lkdist_test, modules/lk (CSRF, заголовки, режим ожидания),
живые lkcoverage/lkunpriced, scripts/lk-sec-smoke.sh — проверка работающего кабинета снаружи.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zuevav
2026-09-17 23:47:10 +03:00
parent 4b18579956
commit 2a7442c935
33 changed files with 3062 additions and 209 deletions
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# Проверка защиты работающего кабинета снаружи. Ничего не меняет: только запросы к страницам.
B=${1:-http://127.0.0.1:8099}
pass=0; fail=0
ok(){ echo " ✓ $1"; pass=$((pass+1)); }
no(){ echo " ✗ $1"; fail=$((fail+1)); }
chk(){ if [ "$2" = "$3" ]; then ok "$1"; else no "$1 (получено: $2, ожидали: $3)"; fi }
H=$(curl -s -m8 -D- -o /dev/null $B/login)
echo "Заголовки защиты:"
for h in "X-Frame-Options: DENY" "X-Content-Type-Options: nosniff" "Referrer-Policy: same-origin" "frame-ancestors 'none'" "default-src 'none'" "no-store" "Permissions-Policy"; do
echo "$H" | grep -qi -- "$h" && ok "$h" || no "нет: $h"; done
echo "Куки:"
echo "$H" | grep -i "set-cookie: bj_lk_csrf" | grep -qi "httponly" && ok "токен формы HttpOnly" || no "токен формы без HttpOnly"
echo "$H" | grep -i "set-cookie: bj_lk_csrf" | grep -qi "samesite=strict" && ok "SameSite=Strict" || no "нет SameSite=Strict"
HS=$(curl -s -m8 -D- -o /dev/null -H "X-Forwarded-Proto: https" -H "Host: lk.example.ru" $B/login)
echo "$HS" | grep -i "set-cookie" | grep -qi "secure" && ok "за nginx с TLS куки получают Secure" || no "куки без Secure за TLS"
echo "$HS" | grep -qi "strict-transport-security" && ok "HSTS за TLS" || no "нет HSTS за TLS"
echo "Сторонние ресурсы:"
P=$(curl -s -m8 $B/login)
echo "$P" | grep -qiE "https?://[^\"' ]*(googleapis|gstatic|cdn|jsdelivr|unpkg)" && no "страница тянет сторонние ресурсы" || ok "сторонних адресов на странице нет"
echo "$P" | grep -qi "<script" && no "на странице есть скрипты" || ok "скриптов на странице входа нет"
echo "Страницы без входа:"
for p in / /portfolio /orders /order /operations /income /documents /profile /clients /security?isin=RU0009029540; do
c=$(curl -s -m8 -o /dev/null -w "%{http_code} %{redirect_url}" $B$p); echo "$c" | grep -q "^303 .*/login" && ok "$p → вход" || no "$p без входа: $c"; done
echo "Формы без токена:"
chk "POST /order без сессии" "$(curl -s -m8 -o /dev/null -w '%{http_code}' -X POST -d 'isin=RU0009029540' $B/order)" "303"
c=$(curl -s -m8 -X POST -d "login=x&password=y" $B/login | grep -c "Форма устарела"); chk "POST /login без токена отклонён" "$c" "1"
R=$(curl -s -m8 -D- -o /dev/null -b "bj_lk=abcdef0123456789abcdef0123456789abcdef0123456789" "$B/impersonate?contract=BR-1")
echo "$R" | grep -i "^set-cookie: bj_lk=" | grep -qv "abcdef0123456789abcdef0123456789abcdef0123456789" && no "GET /impersonate выдал новую сессию" || ok "GET /impersonate не переключает сессию"
echo "$R" | grep -qi "set-cookie: bj_lk_as=." && no "GET /impersonate включил режим просмотра" || ok "GET /impersonate не включает режим просмотра клиента"
echo "Служебные точки:"
chk "/health без ключа — только признак жизни" "$(curl -s -m8 $B/health)" '{"ok":true}'
chk "/agent/tasks в прямом режиме закрыт" "$(curl -s -m8 -o /dev/null -w '%{http_code}' $B/agent/tasks)" "404"
b=$(curl -s -m8 -L --path-as-is $B/assets/../lk.go | grep -c "^package lk"); chk "/assets/../lk.go — обход каталога не отдаёт файлы" "$b" "0"
b=$(curl -s -m8 --path-as-is "$B/assets/%2e%2e/%2e%2e/etc/passwd" | grep -c "root:"); chk "/assets/%2e%2e/… — файлы системы недоступны" "$b" "0"
chk "/assets/ — листинга нет" "$(curl -s -m8 -o /dev/null -w '%{http_code}' $B/assets/)" "404"
echo "Размер запроса:"
head -c 2500000 /dev/zero | tr '\0' 'a' > big.bin
c=$(curl -s -m15 -o /dev/null -w '%{http_code}' -X POST --data-binary @big.bin -H "Content-Type: application/x-www-form-urlencoded" $B/login); rm -f big.bin
[ "$c" != "303" ] && ok "тело 2,5 МБ не принято как вход (код $c)" || no "большое тело принято"
echo "Подделка сессии:"
c=$(curl -s -m8 -o /dev/null -w "%{http_code} %{redirect_url}" -b "bj_lk=0000000000000000000000000000000000000000000000000; bj_lk_admin=1" $B/clients)
b=$(curl -s -m8 -b "bj_lk=0000000000000000000000000000000000000000000000000; bj_lk_admin=1" $B/clients | grep -c "Открыть кабинет")
chk "кука bj_lk_admin=1 без сессии не даёт списка клиентов" "$b" "0"
echo; echo "ИТОГО: пройдено $pass, не пройдено $fail"