d975957335
По результатам security-аудита (docs, run-1). Три верифицированные находки. H1 (HIGH) — fail-open аутентификация. loadUsers() при ошибке чтения/парсинга users.json молча возвращал пустой стор → authOpen() считал это первичной настройкой → после удаления first-run файла весь /admin отдавался без входа. Теперь: постоянный маркер configured (пишется при создании первого админа и бэкфиллится при загрузке валидного стора); authOpen() открыт только на реально свежей системе; при пустом/битом сторе на настроенной системе — 503 и блокировка до восстановления файла (authLockedOut). Тест TestAuthFailClosed (4 сценария). H2 (HIGH) — интернет-слой доверял клиентскому X-Forwarded-For/X-Real-IP, что позволяло обойти лимит входа по IP и травить аудит. Теперь заголовкам доверяем только от доверенного прокси (по умолчанию loopback; расширяется BJ_TRUSTED_PROXIES), из XFF берём правый элемент. Исправлено и в Go-модуле (modules/lk/security.go), и в PHP-ретрансляторе. Тесты TestClientIPTrust (Go) и прогон логики PHP. M2 (MEDIUM) — POST /order (поручение на списание бумаг) не проверял CSRF, в отличие от /login и /impersonate. Добавлен csrfOK (форма уже несла токен). Тест TestOrderCSRF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
201 lines
8.4 KiB
Go
201 lines
8.4 KiB
Go
package lk
|
|
|
|
// security.go — защита кабинета: заголовки, куки, CSRF, ограничение тела запроса и
|
|
// подстраховка от паники. Кабинет стоит в интернете и показывает персональные данные,
|
|
// поэтому всё это включено всегда, отдельной настройки «выключить» нет.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"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 на куки этого запроса. Заголовок X-BJ-Preview
|
|
// ставит само ядро, когда кабинет запущен на его порту для просмотра по http из локальной
|
|
// сети; в конвертах ретрансляторов такой заголовок отбрасывается.
|
|
func secureCookies(r *http.Request) bool {
|
|
if r.Header.Get("X-BJ-Preview") == "1" && !httpsRequest(r) {
|
|
return false
|
|
}
|
|
return httpsRequest(r) || !localRequest(r)
|
|
}
|
|
|
|
// trustedProxies — сети, которым разрешено сообщать адрес клиента заголовками
|
|
// X-Real-IP / X-Forwarded-For. По умолчанию — только loopback (nginx на том же
|
|
// хосте). Расширяется списком CIDR/адресов в BJ_TRUSTED_PROXIES (через запятую).
|
|
var trustedProxies = loadTrustedProxies(os.Getenv("BJ_TRUSTED_PROXIES"))
|
|
|
|
func loadTrustedProxies(s string) []*net.IPNet {
|
|
var out []*net.IPNet
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
if part == "" {
|
|
continue
|
|
}
|
|
if !strings.Contains(part, "/") {
|
|
if strings.Contains(part, ":") {
|
|
part += "/128"
|
|
} else {
|
|
part += "/32"
|
|
}
|
|
}
|
|
if _, n, err := net.ParseCIDR(part); err == nil {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// peerTrusted — непосредственный источник запроса (адрес соединения) — доверенный
|
|
// прокси: loopback или явно указанный в BJ_TRUSTED_PROXIES.
|
|
func peerTrusted(host string) bool {
|
|
ip := net.ParseIP(host)
|
|
if ip == nil {
|
|
return false
|
|
}
|
|
if ip.IsLoopback() {
|
|
return true
|
|
}
|
|
for _, n := range trustedProxies {
|
|
if n.Contains(ip) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// clientIP — адрес посетителя кабинета. Заголовки X-Real-IP / X-Forwarded-For
|
|
// подставляет клиент, поэтому доверяем им ТОЛЬКО когда запрос пришёл от доверенного
|
|
// прокси (по умолчанию nginx на том же хосте). Иначе адрес берём из соединения —
|
|
// иначе злоумышленник подделал бы свой IP и обошёл лимит входа и журнал аудита.
|
|
func clientIP(r *http.Request) string {
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
host = r.RemoteAddr
|
|
}
|
|
if peerTrusted(host) {
|
|
if v := strings.TrimSpace(r.Header.Get("X-Real-IP")); v != "" {
|
|
return v
|
|
}
|
|
if v := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); v != "" {
|
|
// самый правый элемент добавляет наш доверенный прокси (его peer);
|
|
// левые может подставить клиент, поэтому берём правый.
|
|
parts := strings.Split(v, ",")
|
|
return strings.TrimSpace(parts[len(parts)-1])
|
|
}
|
|
}
|
|
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)
|
|
})
|
|
}
|