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 на куки этого запроса. Заголовок 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) } // 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) }) }