Кабинет клиента: документы и корпоративные действия, дистрибутив, обратный канал, защита
Данные - корпоративные действия по бумагам клиента (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:
+140
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user