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
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}