Безопасность: fail-closed auth (H1), доверенный прокси для IP (H2), CSRF на /order (M2)

По результатам 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
This commit is contained in:
zuevav
2026-09-18 21:25:35 +03:00
parent 9273fc492c
commit d975957335
7 changed files with 297 additions and 21 deletions
+61 -8
View File
@@ -11,6 +11,7 @@ import (
"log"
"net"
"net/http"
"os"
"strings"
)
@@ -56,17 +57,69 @@ func secureCookies(r *http.Request) bool {
return httpsRequest(r) || !localRequest(r)
}
// clientIP — адрес посетителя кабинета (за nginx — из X-Real-IP / X-Forwarded-For).
// 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 {
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
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
}