Files
Bridge-and-Join-s/internal/lkgateway/lksecurity_test.go
T
zuevav 2a7442c935 Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные
- корпоративные действия по бумагам клиента (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>
2026-09-17 23:47:10 +03:00

188 lines
7.8 KiB
Go

package lkgateway
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Тесты безопасности кабинета на стороне ядра: что лежит на диске, как живут сессии,
// как ядро отвечает на перебор и на запросы без ключа.
func sessionsInTemp(t *testing.T) *lkSessions {
t.Helper()
saved := lkSessionsFile
lkSessionsFile = filepath.Join(t.TempDir(), "lk-sessions.json")
t.Cleanup(func() { lkSessionsFile = saved })
return newLKSessions()
}
func reqFrom(ip, agent string) *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/lk/portfolio", nil)
r.Header.Set("X-BJ-Client-IP", ip)
r.Header.Set("X-BJ-Client-Agent", agent)
return r
}
// На диске нет ни токена, ни персональных данных клиента.
func TestLKSessionFileHasNoTokenOrPII(t *testing.T) {
s := sessionsInTemp(t)
tok, err := s.create(lkSession{UserID: 7, Login: "ivanov", Contract: "БР-100", CanTransfer: true}, reqFrom("203.0.113.5", "Firefox"))
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(lkSessionsFile)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte(tok)) {
t.Fatal("в файле сессий лежит живой токен")
}
for _, leak := range []string{"full_name", "doc_series", "doc_number", "email", "phone", "last_name"} {
if bytes.Contains(raw, []byte(leak)) {
t.Errorf("в файле сессий есть поле с персональными данными: %s", leak)
}
}
if st, _ := os.Stat(lkSessionsFile); st.Mode().Perm() != 0o600 {
t.Errorf("права файла сессий %o, нужны 0600", st.Mode().Perm())
}
// после перезапуска сессия поднимается по отпечатку
if _, ok := newLKSessions().get(tok, reqFrom("203.0.113.5", "Firefox")); !ok {
t.Fatal("сессия не пережила перезапуск")
}
}
// Украденный токен с другого браузера или из другой сети не подходит и гасит сессию.
func TestLKSessionBoundToDevice(t *testing.T) {
s := sessionsInTemp(t)
s.bindNetwork = func() bool { return true }
tok, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox"))
if _, ok := s.get(tok, reqFrom("203.0.113.77", "Firefox")); !ok {
t.Fatal("тот же клиент в той же сети /24 должен проходить")
}
if _, ok := s.get(tok, reqFrom("203.0.113.5", "curl/8")); ok {
t.Fatal("токен принят с другого браузера")
}
if _, ok := s.get(tok, reqFrom("203.0.113.5", "Firefox")); ok {
t.Fatal("после попытки с чужого устройства сессия должна быть погашена")
}
tok2, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox"))
if _, ok := s.get(tok2, reqFrom("198.51.100.9", "Firefox")); ok {
t.Fatal("токен принят из другой сети при включённой привязке к сети")
}
// без привязки к сети мобильный клиент со сменившимся адресом остаётся в кабинете
s.bindNetwork = nil
tok3, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, reqFrom("203.0.113.5", "Firefox"))
if _, ok := s.get(tok3, reqFrom("198.51.100.9", "Firefox")); !ok {
t.Fatal("по умолчанию смена адреса не должна выбрасывать клиента")
}
}
// Сессия не живёт дольше абсолютного срока, даже если ей всё время пользуются.
func TestLKSessionAbsoluteLifetime(t *testing.T) {
s := sessionsInTemp(t)
r := reqFrom("203.0.113.5", "Firefox")
tok, _ := s.create(lkSession{UserID: 1, Contract: "БР-1"}, r)
h := lkTokenHash(tok)
s.mu.Lock()
v := s.data[h]
v.CreatedAt = time.Now().Add(-lkSessionMaxAge - time.Minute)
v.ExpiresAt = time.Now().Add(time.Hour) // скользящий срок ещё не вышел
s.data[h] = v
s.mu.Unlock()
if _, ok := s.get(tok, r); ok {
t.Fatal("сессия пережила абсолютный срок")
}
if _, ok := s.get("", r); ok {
t.Fatal("пустой токен принят")
}
}
// Сотрудник в кабинете клиента — только просмотр, что бы ни стояло в учётной записи.
func TestLKVirtualSessionIsReadOnly(t *testing.T) {
s := sessionsInTemp(t)
u, err := s.user(nil, nil, lkSession{Virtual: true, Contract: "БР-5", VirtualName: "Клиент", ImpersonatedBy: "manager", CanTransfer: true})
if err != nil {
t.Fatal(err)
}
if u.CanTransfer || u.ImpersonatedBy != "manager" || u.Contract != "БР-5" {
t.Fatalf("сессия сотрудника: %+v", u)
}
}
// API кабинета: без ключа, с чужим ключом, перебор пароля.
func TestLKAPIKeyAndBruteForce(t *testing.T) {
sessionsInTemp(t)
rc, err := NewRuntimeConfig(t.TempDir() + "/setup.json")
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerLKAPI(mux, rc, nil)
call := func(method, path, key, body string) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, path, strings.NewReader(body))
if key != "" {
r.Header.Set("X-BJ-Key", key)
}
r.Header.Set("X-BJ-Client-IP", "203.0.113.200")
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
return w
}
// ключа нет в настройках — API выключен целиком
if w := call("GET", "/api/lk/ping", "что-угодно", ""); w.Code != http.StatusServiceUnavailable {
t.Fatalf("API без ключа в настройках: %d", w.Code)
}
cfg := rc.Snapshot().LK
cfg.APIKey = "lk-правильный-ключ"
_ = rc.UpdateLK(cfg)
for _, path := range []string{"/api/lk/ping", "/api/lk/portfolio", "/api/lk/operations", "/api/lk/income",
"/api/lk/documents", "/api/lk/corpactions", "/api/lk/claims", "/api/lk/admin/clients"} {
if w := call("GET", path, "", ""); w.Code != http.StatusUnauthorized {
t.Errorf("%s без ключа: код %d", path, w.Code)
}
if w := call("GET", path, "lk-чужой-ключ", ""); w.Code != http.StatusUnauthorized {
t.Errorf("%s с чужим ключом: код %d", path, w.Code)
}
}
// с ключом, но без сессии клиента — данных нет
for _, path := range []string{"/api/lk/portfolio", "/api/lk/operations", "/api/lk/income", "/api/lk/documents", "/api/lk/admin/clients"} {
if w := call("GET", path, cfg.APIKey, ""); w.Code != http.StatusUnauthorized {
t.Errorf("%s без сессии: код %d", path, w.Code)
}
}
// перебор: после серии неудач ядро перестаёт проверять пароль
body, _ := json.Marshal(map[string]string{"login": "brute-test-" + t.Name(), "password": "неверный-пароль"})
var blocked bool
for i := 0; i < 12; i++ {
if w := call("POST", "/api/lk/login", cfg.APIKey, string(body)); w.Code == http.StatusTooManyRequests {
blocked = true
break
}
}
if !blocked {
t.Fatal("перебор пароля не ограничен")
}
}
// Требования к паролю и экранирование шаблона LIKE.
func TestLKPasswordPolicyAndLikeEscape(t *testing.T) {
for _, bad := range []string{"", "123456", "короткий1", "толькобуквыбезцифр", "1234567890123"} {
if lkCheckPassword(bad) == nil {
t.Errorf("слабый пароль принят: %q", bad)
}
}
if err := lkCheckPassword("Надёжный-пароль-2026"); err != nil {
t.Errorf("хороший пароль отклонён: %v", err)
}
if got := likeEscape("БР-1%_[x"); got != "БР-1[%][_][[]x" {
t.Errorf("likeEscape: %q", got)
}
}