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

Данные
- корпоративные действия по бумагам клиента (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
+140 -1
View File
@@ -1,12 +1,15 @@
package lk
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// fakeBJ — API bj-server для кабинета: проверка ключа, вход и портфель.
@@ -91,9 +94,12 @@ func TestLoginPortfolioHealth(t *testing.T) {
}
h := s.Handler()
form := url.Values{"login": {"client"}, "password": {"secret"}}
// Форма входа подписана токеном CSRF: без него запрос не выполняется.
csrf := csrfFromPage(t, h)
form := url.Values{"login": {"client"}, "password": {"secret"}, "csrf": {csrf.Value}}
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
@@ -242,3 +248,136 @@ func TestBoardPages(t *testing.T) {
}
}
}
// csrfFromPage — открыть страницу входа и взять с неё токен формы.
func csrfFromPage(t *testing.T, h http.Handler) *http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
for _, c := range rec.Result().Cookies() {
if c.Name == csrfCookie {
return c
}
}
t.Fatal("страница входа не выдала токен формы")
return nil
}
// TestSecurityHeaders — кабинет отдаёт заголовки защиты и не разрешает кэшировать страницы с данными.
func TestSecurityHeaders(t *testing.T) {
bj := fakeBJ(t)
s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"})
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
for h, want := range map[string]string{
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
} {
if got := rec.Header().Get(h); got != want {
t.Errorf("%s = %q, ожидали %q", h, got, want)
}
}
if csp := rec.Header().Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") {
t.Errorf("CSP без запрета встраивания: %q", csp)
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q", cc)
}
if body := rec.Body.String(); strings.Contains(body, "fonts.googleapis.com") || strings.Contains(body, "fonts.gstatic.com") {
t.Error("страница тянет шрифты со стороннего сайта")
}
}
// TestImpersonateNeedsPost — вход сотрудника в кабинет клиента не выполняется по ссылке.
func TestImpersonateNeedsPost(t *testing.T) {
bj := fakeBJ(t)
s, err := New(Config{BJURL: bj.URL, BJKey: "lk-key"})
if err != nil {
t.Fatal(err)
}
h := s.Handler()
req := httptest.NewRequest(http.MethodGet, "/impersonate?contract=BR-1", nil)
req.AddCookie(&http.Cookie{Name: "bj_lk", Value: "sess-1"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if loc := rec.Header().Get("Location"); loc != "/clients" {
t.Fatalf("GET /impersonate: код %d, Location %q — должен отправлять к списку клиентов без переключения", rec.Code, loc)
}
for _, c := range rec.Result().Cookies() {
if c.Name == "bj_lk" && c.Value != "" && c.Value != "sess-1" {
t.Fatal("GET /impersonate переключил сессию")
}
}
}
// TestAgentMode — режим ожидания: запрос страницы попадает в очередь, ядро забирает
// его своим подключением и возвращает ответ.
func TestAgentMode(t *testing.T) {
s, err := New(Config{BJKey: "lk-key", Agent: true})
if err != nil {
t.Fatal(err)
}
if !s.Configured() {
t.Fatal("в режиме ожидания адрес ядра не нужен")
}
h := s.Handler()
// «ядро»: подключается к кабинету, забирает запрос и отвечает
done := make(chan string, 1)
go func() {
for i := 0; i < 50; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/agent/tasks", nil)
req.Header.Set("X-BJ-Key", "lk-key")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
time.Sleep(10 * time.Millisecond)
continue
}
var task struct{ ID, Method, Path string }
if json.Unmarshal(rec.Body.Bytes(), &task) != nil {
continue
}
done <- task.Path
body := base64.StdEncoding.EncodeToString([]byte(`{"session":"sess-1","full_name":"Клиент","contract":"BR-1"}`))
payload := fmt.Sprintf(`{"id":%q,"status":200,"body":%q}`, task.ID, body)
rr := httptest.NewRecorder()
rq := httptest.NewRequest(http.MethodPost, "/agent/result", strings.NewReader(payload))
rq.Header.Set("X-BJ-Key", "lk-key")
h.ServeHTTP(rr, rq)
return
}
}()
csrf := csrfFromPage(t, h)
form := url.Values{"login": {"client"}, "password": {"secret"}, "csrf": {csrf.Value}}
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(csrf)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("вход через очередь: код %d, тело %s", rec.Code, rec.Body.String())
}
select {
case path := <-done:
if path != "/api/lk/login" {
t.Fatalf("ядро получило %q", path)
}
default:
t.Fatal("ядро не получило запрос")
}
// чужой ключ к каналу не подпускаем
rec = httptest.NewRecorder()
bad := httptest.NewRequest(http.MethodGet, "/agent/tasks", nil)
bad.Header.Set("X-BJ-Key", "не тот ключ")
h.ServeHTTP(rec, bad)
if rec.Code != http.StatusForbidden {
t.Fatalf("чужой ключ принят: код %d", rec.Code)
}
}