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) } }