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>
188 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|