Безопасность: 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
+65 -8
View File
@@ -37,12 +37,14 @@ import (
)
const (
usersFile = "/var/lib/bj/.bj/users.json"
sessionsFile = "/var/lib/bj/.bj/sessions.json"
sessionCookie = "bj_session"
sessionMaxLife = 24 * time.Hour
)
// usersFile — стор учётных записей (var, чтобы подменять в тестах).
var usersFile = "/var/lib/bj/.bj/users.json"
// ---------- роли и права ----------
type roleDef struct {
@@ -224,19 +226,51 @@ type userStore struct {
var globalUsers = loadUsers()
// usersLoadFailed — файл учётных записей есть, но прочитать/разобрать его не
// удалось (повреждение, права). В этом случае НЕЛЬЗЯ открывать админку без входа:
// пустой стор из-за сбоя не должен выглядеть как «первичная настройка».
var usersLoadFailed bool
func loadUsers() *userStore {
s := &userStore{users: map[string]*User{}}
if b, err := os.ReadFile(usersFile); err == nil {
b, err := os.ReadFile(usersFile)
switch {
case err == nil:
var list []*User
if json.Unmarshal(b, &list) == nil {
for _, u := range list {
s.users[strings.ToLower(u.Login)] = u
}
if json.Unmarshal(b, &list) != nil {
usersLoadFailed = true // файл есть, но это не корректный JSON — сбой, не «нет пользователей»
return s
}
for _, u := range list {
s.users[strings.ToLower(u.Login)] = u
}
if len(s.users) > 0 {
markConfigured() // бэкфилл маркера для установок, заведённых до этой защиты
}
case os.IsNotExist(err):
// файла нет: либо действительно первый запуск, либо стор потеряли на
// настроенной системе — различаем по постоянному маркеру ниже (authOpen).
default:
usersLoadFailed = true // есть, но недоступен (права и т. п.)
}
return s
}
// configuredMarker — постоянный признак «система уже была настроена» (заведён
// хотя бы один пользователь). В отличие от first-run-файла он НЕ удаляется, и по
// нему видно, что нулевой стор — это потеря данных, а не свежая установка.
var configuredMarker = "/var/lib/bj/.bj/configured"
func isConfigured() bool { _, err := os.Stat(configuredMarker); return err == nil }
// markConfigured — отметить систему настроенной (идемпотентно, best-effort).
func markConfigured() {
if isConfigured() {
return
}
_ = os.WriteFile(configuredMarker, []byte(time.Now().UTC().Format(time.RFC3339)+"\n"), 0o600)
}
func (s *userStore) save() error {
list := s.list()
b, _ := json.MarshalIndent(list, "", " ")
@@ -244,7 +278,13 @@ func (s *userStore) save() error {
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, usersFile)
if err := os.Rename(tmp, usersFile); err != nil {
return err
}
if len(list) > 0 {
markConfigured() // с этого момента нулевой стор трактуется как потеря данных
}
return nil
}
func (s *userStore) list() []*User {
@@ -328,7 +368,17 @@ func checkPassword(hash, p string) bool {
}
// authOpen — режим первичной настройки: пользователей нет, вход не требуется.
func authOpen() bool { return globalUsers.Count() == 0 }
// authOpen — режим первичной настройки: вход не требуется. Открываем ТОЛЬКО на
// действительно свежей системе — стор пуст, не было сбоя загрузки и система ещё
// не отмечена настроенной. Потеря/порча users.json на настроенной системе сюда
// НЕ попадает (fail-closed): её ловит запрет в authMiddleware.
func authOpen() bool { return globalUsers.Count() == 0 && !usersLoadFailed && !isConfigured() }
// authLockedOut — стор недоступен/пуст на уже настроенной системе: админку
// блокируем, пока файл не восстановят.
func authLockedOut() bool {
return globalUsers.Count() == 0 && (usersLoadFailed || isConfigured())
}
// ---------- сессии ----------
@@ -890,6 +940,13 @@ func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler {
next.ServeHTTP(&authRW{ResponseWriter: w, user: nil}, r)
return
}
if authLockedOut() {
// Учётные записи потеряны/повреждены на настроенной системе.
// НЕ открываем админку без входа — блокируем до восстановления файла.
log.Printf("auth: админ-панель заблокирована — стор учётных записей недоступен (usersLoadFailed=%v, configured=%v)", usersLoadFailed, isConfigured())
http.Error(w, "Учётные записи недоступны — админ-панель заблокирована. Восстановите /var/lib/bj/.bj/users.json из резервной копии и перезапустите bj-server.", http.StatusServiceUnavailable)
return
}
var u *User
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
if s := globalSessions.Get(c.Value, rc.Snapshot().Auth.idle()); s != nil {
@@ -0,0 +1,63 @@
package lkgateway
import (
"os"
"path/filepath"
"testing"
)
// H1: пустой/битый users.json на настроенной системе НЕ должен открывать админку.
func TestAuthFailClosed(t *testing.T) {
dir := t.TempDir()
oldUsers, oldMarker, oldStore, oldFailed := usersFile, configuredMarker, globalUsers, usersLoadFailed
t.Cleanup(func() {
usersFile, configuredMarker, globalUsers, usersLoadFailed = oldUsers, oldMarker, oldStore, oldFailed
})
usersFile = filepath.Join(dir, "users.json")
configuredMarker = filepath.Join(dir, "configured")
reload := func() {
usersLoadFailed = false
globalUsers = loadUsers()
}
// 1) свежая система: файла нет, маркера нет → открыто для первичной настройки
reload()
if !authOpen() || authLockedOut() {
t.Fatalf("свежая система: ожидали open, got open=%v locked=%v", authOpen(), authLockedOut())
}
// 2) корректный стор с админом → не открыто, не заблокировано, маркер создан
if err := os.WriteFile(usersFile, []byte(`[{"login":"admin","role":"admin","active":true}]`), 0o600); err != nil {
t.Fatal(err)
}
reload()
if authOpen() || authLockedOut() {
t.Fatalf("настроенная система: open=%v locked=%v", authOpen(), authLockedOut())
}
if !isConfigured() {
t.Fatal("маркер настройки не создан при загрузке валидного стора")
}
// 3) файл ПОВРЕЖДЁН (битый JSON) → fail-closed
if err := os.WriteFile(usersFile, []byte(`{ не json`), 0o600); err != nil {
t.Fatal(err)
}
reload()
if authOpen() {
t.Error("битый users.json открыл админку без входа — fail-open!")
}
if !authLockedOut() {
t.Error("битый users.json должен блокировать админку")
}
// 4) файл ПРОПАЛ, но система была настроена (маркер есть) → fail-closed
os.Remove(usersFile)
reload()
if authOpen() {
t.Error("пропавший users.json на настроенной системе открыл админку — fail-open!")
}
if !authLockedOut() {
t.Error("пропавший стор на настроенной системе должен блокировать админку")
}
}