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) }) }