diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 67385b0..0df51f2 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -917,6 +917,36 @@ func clientIP(r *http.Request) string { return host } +// sameOriginRequest — изменяющий запрос пришёл с нашего же сайта. Сверяем хост из +// Origin (или Referer, если Origin нет) с Host запроса. Запрос без обоих заголовков +// (curl, старые клиенты) пропускаем — CSRF из браузера без них не бывает. +func sameOriginRequest(r *http.Request) bool { + src := r.Header.Get("Origin") + if src == "" || src == "null" { + src = r.Header.Get("Referer") + } + if src == "" { + return true + } + u, err := url.Parse(src) + if err != nil || u.Host == "" { + return false + } + if strings.EqualFold(u.Host, r.Host) { + return true + } + // За прокси Host может быть переписан; браузер же видит внешний адрес — + // его передаёт X-Forwarded-Host. Доверяем только от loopback-прокси. + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + if fh := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); fh != "" { + return strings.EqualFold(u.Host, fh) + } + } + } + return false +} + // reqIsHTTPS — запрос защищён TLS: напрямую или через доверенный (loopback) // TLS-терминирующий прокси, сообщивший X-Forwarded-Proto: https. Нужно, чтобы за // nginx на том же хосте кука сессии всё равно получала признак Secure. @@ -946,6 +976,14 @@ func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler { next.ServeHTTP(w, r) return } + // CSRF для изменяющих запросов админки: cookie сессии — SameSite=Lax, а вторым + // слоем — источник запроса должен быть тем же сайтом (Origin, иначе Referer). + // Токена в формах нет, поэтому это единственная явная проверка; браузеры всегда + // шлют Origin на кросс-сайтовый POST. + if r.Method != http.MethodGet && r.Method != http.MethodHead && !sameOriginRequest(r) { + http.Error(w, "запрос с чужого сайта отклонён", http.StatusForbidden) + return + } if firstRunPending() { // свежая установка: сначала администратор по одноразовому коду if isAjax(r) { http.Error(w, "требуется первичная настройка", http.StatusUnauthorized) diff --git a/internal/lkgateway/lkapi.go b/internal/lkgateway/lkapi.go index ec2e837..9b1bb1d 100644 --- a/internal/lkgateway/lkapi.go +++ b/internal/lkgateway/lkapi.go @@ -134,6 +134,10 @@ func registerLKAPI(mux *http.ServeMux, rc *RuntimeConfig, svc *Service) { lkJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "нужен POST"}) return } + // Проверка «админа ещё нет» и создание — под одним замком: иначе два + // параллельных bootstrap оба увидят «нет» и заведут двух администраторов. + lkBootstrapMu.Lock() + defer lkBootstrapMu.Unlock() has, err := lkHasAdmin(r.Context(), rc) if err != nil { lkJSON(w, http.StatusBadGateway, map[string]string{"error": "база кабинета недоступна: " + err.Error()}) diff --git a/internal/lkgateway/lkbootstrap_mu.go b/internal/lkgateway/lkbootstrap_mu.go new file mode 100644 index 0000000..29583dd --- /dev/null +++ b/internal/lkgateway/lkbootstrap_mu.go @@ -0,0 +1,8 @@ +package lkgateway + +import "sync" + +// lkBootstrapMu — замок первичного создания администратора кабинета +// (/api/lk/admin/bootstrap): проверка «админа ещё нет» и создание выполняются +// атомарно, чтобы два параллельных запроса не завели двух администраторов. +var lkBootstrapMu sync.Mutex diff --git a/internal/lkgateway/lksession.go b/internal/lkgateway/lksession.go index 343f4a1..22ce9c3 100644 --- a/internal/lkgateway/lksession.go +++ b/internal/lkgateway/lksession.go @@ -265,9 +265,13 @@ func (s *lkSessions) user(ctx context.Context, rc *RuntimeConfig, v lkSession) ( return LKUser{}, errors.New("учётная запись заблокирована") } // Сотрудник в кабинете клиента — только просмотр, что бы ни стояло в учётке. + // IsAdmin тоже снимаем: иначе, открыв кабинет другого администратора, сотрудник + // действовал бы от его имени с полными правами. Свои админ-действия он делает из + // собственной сессии (bj_lk_admin_sess), а не из сессии просмотра. if v.ImpersonatedBy != "" { u.ImpersonatedBy = v.ImpersonatedBy u.CanTransfer = false + u.IsAdmin = false } return u, nil } diff --git a/internal/lkgateway/units.go b/internal/lkgateway/units.go index 0d4813d..47749d0 100644 --- a/internal/lkgateway/units.go +++ b/internal/lkgateway/units.go @@ -109,7 +109,8 @@ func (h *setupHandlers) unitRestart(w http.ResponseWriter, r *http.Request) { unit := strings.TrimSpace(r.FormValue("unit")) known := false for _, c := range release.Components { - if c.Unit == unit { + // пустой Unit у bj-component-install — не служба; пустая строка не должна проходить + if c.Unit != "" && c.Unit == unit { known = true } } diff --git a/modules/lk/lk.go b/modules/lk/lk.go index dcd0f40..4b04337 100644 --- a/modules/lk/lk.go +++ b/modules/lk/lk.go @@ -1221,6 +1221,12 @@ func (s *Server) buildHandler() http.Handler { // Администратор: вернуться из кабинета клиента mux.HandleFunc("/back-to-admin", func(w http.ResponseWriter, r *http.Request) { + // Меняет состояние (гасит сессию просмотра, переключает cookie) — только POST + // своей формы с CSRF, как и остальные изменяющие действия. + if r.Method != http.MethodPost || !csrfOK(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } // Сессию просмотра гасим в ядре, а не бросаем: иначе выданные сотруднику // токены чужих кабинетов копились бы и жили ещё 12 часов. if c, err := r.Cookie("bj_lk_admin_sess"); err == nil && c.Value != "" { diff --git a/modules/lk/relay-php/.htaccess b/modules/lk/relay-php/.htaccess index 5dec5a9..ebd2170 100644 --- a/modules/lk/relay-php/.htaccess +++ b/modules/lk/relay-php/.htaccess @@ -7,6 +7,6 @@ DirectoryIndex index.php RewriteRule ^ index.php [L,QSA] # Служебные файлы наружу не отдаём. - + Require all denied diff --git a/modules/lk/relay-php/index.php b/modules/lk/relay-php/index.php index 529982e..89836b0 100644 --- a/modules/lk/relay-php/index.php +++ b/modules/lk/relay-php/index.php @@ -372,7 +372,11 @@ function agentResult(array $cfg): never function envelopeFromRequest(): array { - $body = (string)file_get_contents('php://input'); + // L11: не читать тело целиком, если заявленный размер уже превышает лимит. + if ((int)($_SERVER['CONTENT_LENGTH'] ?? 0) > RELAY_MAX_BODY) { + throw new LengthException('Запрос слишком большой'); + } + $body = (string)file_get_contents('php://input', false, null, 0, RELAY_MAX_BODY + 1); if (strlen($body) > RELAY_MAX_BODY) { throw new LengthException('Запрос слишком большой'); }