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

Данные
- корпоративные действия по бумагам клиента (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
View File
@@ -0,0 +1,140 @@
package lk
// security.go — защита кабинета: заголовки, куки, CSRF, ограничение тела запроса и
// подстраховка от паники. Кабинет стоит в интернете и показывает персональные данные,
// поэтому всё это включено всегда, отдельной настройки «выключить» нет.
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"log"
"net"
"net/http"
"strings"
)
// secureEqual — сравнение секретов за постоянное время.
func secureEqual(a, b string) bool {
if a == "" || b == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
}
// httpsRequest — запрос пришёл по TLS (напрямую или через nginx).
func httpsRequest(r *http.Request) bool {
if r.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]), "https")
}
// localRequest — обращение на сам сервер (проверка при установке, автотесты).
// Только для таких запросов куки выставляются без признака Secure, иначе браузер
// их не вернёт по http и войти в кабинет на стенде будет нельзя.
func localRequest(r *http.Request) bool {
host := r.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
host = strings.ToLower(strings.TrimSpace(host))
if host == "localhost" || strings.HasSuffix(host, ".local") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// secureCookies — ставить ли признак Secure на куки этого запроса.
func secureCookies(r *http.Request) bool { return httpsRequest(r) || !localRequest(r) }
// clientIP — адрес посетителя кабинета (за nginx — из X-Real-IP / X-Forwarded-For).
func clientIP(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
return v
}
if v := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); v != "" {
return strings.TrimSpace(strings.Split(v, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// ---- CSRF ----
const csrfCookie = "bj_lk_csrf"
// csrfToken — токен этого посетителя: лежит в куке, недоступной скриптам, и
// подставляется в каждую форму. Запрос без совпадающего поля мы не выполняем,
// поэтому чужая страница не сможет отправить форму от имени клиента.
func csrfToken(w http.ResponseWriter, r *http.Request) string {
if c, err := r.Cookie(csrfCookie); err == nil && len(c.Value) >= 32 {
return c.Value
}
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "" // без источника случайности форму не подписываем: пусть лучше не отправится
}
tok := hex.EncodeToString(b)
http.SetCookie(w, &http.Cookie{Name: csrfCookie, Value: tok, Path: "/", MaxAge: cookieTTL,
HttpOnly: true, Secure: secureCookies(r), SameSite: http.SameSiteStrictMode})
return tok
}
// csrfOK — проверка формы: метод POST и поле совпадает с кукой.
func csrfOK(r *http.Request) bool {
c, err := r.Cookie(csrfCookie)
if err != nil || c.Value == "" {
return false
}
_ = r.ParseForm()
return secureEqual(r.FormValue("csrf"), c.Value)
}
// ---- обёртка над всеми страницами ----
// maxBody — сколько принимаем от посетителя. Формы кабинета маленькие; исключение —
// канал ядра (/agent/), где ответ ядра может быть большим.
const maxBody = 1 << 20
func securityHeaders(w http.ResponseWriter, r *http.Request) {
h := w.Header()
// Своих скриптов у кабинета нет, шрифты вшиты в программу — поэтому
// правила строгие: ничего стороннего страница загрузить не может.
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'")
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("Permissions-Policy", "geolocation=(), microphone=(), camera=(), payment=()")
if httpsRequest(r) {
h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
}
// harden — заголовки защиты, запрет кэширования страниц с данными клиента,
// ограничение тела запроса и подстраховка от паники в обработчике.
func harden(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("кабинет: сбой при обработке %s %s: %v", r.Method, r.URL.Path, rec)
http.Error(w, "Внутренняя ошибка кабинета", http.StatusInternalServerError)
}
}()
securityHeaders(w, r)
if !strings.HasPrefix(r.URL.Path, "/agent/") {
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
if !strings.HasPrefix(r.URL.Path, "/assets/") {
// Портфель, доходы, паспортные данные не должны оседать в кэше
// браузера на общем компьютере и в промежуточных прокси.
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate, private")
w.Header().Set("Pragma", "no-cache")
}
}
next.ServeHTTP(w, r)
})
}