diff --git a/internal/lkgateway/auth.go b/internal/lkgateway/auth.go index 312fcf6..67385b0 100644 --- a/internal/lkgateway/auth.go +++ b/internal/lkgateway/auth.go @@ -917,6 +917,24 @@ func clientIP(r *http.Request) string { return host } +// reqIsHTTPS — запрос защищён TLS: напрямую или через доверенный (loopback) +// TLS-терминирующий прокси, сообщивший X-Forwarded-Proto: https. Нужно, чтобы за +// nginx на том же хосте кука сессии всё равно получала признак Secure. +func reqIsHTTPS(r *http.Request) bool { + if r.TLS != nil { + return true + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() { + proto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]) + return strings.EqualFold(proto, "https") + } + return false +} + func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path @@ -1046,7 +1064,7 @@ func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) { loginFailReset(key) _ = globalUsers.update(u.Login, func(x *User) { x.LastLogin = time.Now() }) s := globalSessions.New(u.Login, clientIP(r), r.UserAgent()) - http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil, MaxAge: int(sessionMaxLife.Seconds())}) + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: reqIsHTTPS(r), MaxAge: int(sessionMaxLife.Seconds())}) log.Printf("auth: вход %s (%s, %s) с %s", u.Login, u.Role, u.Source, clientIP(r)) if next == "" || next == "/login" { next = u.Home() diff --git a/internal/lkgateway/keywizard.go b/internal/lkgateway/keywizard.go index 49ce084..833a2ea 100644 --- a/internal/lkgateway/keywizard.go +++ b/internal/lkgateway/keywizard.go @@ -306,12 +306,19 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request) targetDev := strings.TrimSpace(r.FormValue("target_device")) targetMnt := "" if targetDev != "" { + found := false for _, d := range listFlashDrives() { if d.Device == targetDev { - targetMnt = d.Mountpoint + targetMnt, found = d.Mountpoint, true break } } + // Устройство должно быть из списка обнаруженных съёмных: иначе root-воркер + // смонтировал бы произвольный блочный девайс/путь, переданный из формы. + if !found { + h.keyWizFlash(w, r, "Выбранное устройство не найдено среди съёмных носителей — обновите список и повторите.") + return + } } ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) diff --git a/internal/lkgateway/manual_depo.go b/internal/lkgateway/manual_depo.go index a3cf583..07ae787 100644 --- a/internal/lkgateway/manual_depo.go +++ b/internal/lkgateway/manual_depo.go @@ -71,7 +71,9 @@ func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) { defer db.Close() digits := strings.ReplaceAll(q, " ", "") // Действующая версия карточки (LAST_FLAG=1); счета привязаны к исходной записи (SELF_ID). - rows, err := db.QueryContext(ctx, fsql.Man01, sql.Named("q", q+"%"), sql.Named("d", digits)) + // likeEscape: метасимволы LIKE (% _ [) в имени от пользователя означают сами себя, + // иначе поиск «расползается» по всей базе (wildcard-injection). + rows, err := db.QueryContext(ctx, fsql.Man01, sql.Named("q", likeEscape(q)+"%"), sql.Named("d", digits)) if err != nil { return nil, "поиск в DEPO: " + err.Error() } diff --git a/internal/lkgateway/media.go b/internal/lkgateway/media.go index 1f30dd6..524c4ca 100644 --- a/internal/lkgateway/media.go +++ b/internal/lkgateway/media.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "os/exec" + "path" "path/filepath" "strings" "time" @@ -290,6 +291,16 @@ func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) { cctx, cancel := context.WithTimeout(ctx, 5*time.Minute) defer cancel() + // Защита от zip-slip: 7z x сохраняет пути из архива, поэтому запись «../…» ушла + // бы за dst. Сначала СПИСОК архива (не пишет на диск) — если есть абсолютный путь + // или выход за корень, распаковку не выполняем. + if bad, err := archiveUnsafePath(cctx, abs, password); err != nil { + _ = os.RemoveAll(dst) + return Medium{}, err + } else if bad != "" { + _ = os.RemoveAll(dst) + return Medium{}, fmt.Errorf("архив отклонён: небезопасный путь внутри (%s)", bad) + } // 7z x -y -o [-p] — рекурсивное извлечение. args := []string{"x", "-y", "-o" + dst} if password != "" { @@ -313,6 +324,53 @@ func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) { return m, nil } +// archiveUnsafePath — просматривает СПИСОК архива (7z l -slt, без записи на диск) +// и возвращает первый небезопасный путь: абсолютный или выходящий за корень («..»). +// Пустая строка — все пути безопасны. +func archiveUnsafePath(ctx context.Context, archive, password string) (string, error) { + args := []string{"l", "-slt", "-ba"} + if password != "" { + args = append(args, "-p"+password) + } else { + args = append(args, "-p-") + } + args = append(args, archive) + out, err := exec.CommandContext(ctx, "7z", args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("7z l: %w / %s", err, strings.TrimSpace(string(out))) + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimRight(line, "\r") + v, ok := strings.CutPrefix(line, "Path = ") + if !ok { + continue + } + p := strings.TrimSpace(v) + if p != "" && unsafeArchivePath(p) { + return p, nil + } + } + return "", nil +} + +// unsafeArchivePath — путь из архива опасен: абсолютный (Unix/Windows) или +// выходящий за корень распаковки хотя бы одним компонентом «..». +func unsafeArchivePath(p string) bool { + if strings.HasPrefix(p, "/") || strings.HasPrefix(p, `\`) || (len(p) >= 2 && p[1] == ':') { + return true + } + norm := strings.ReplaceAll(p, `\`, "/") + if clean := path.Clean(norm); clean == ".." || strings.HasPrefix(clean, "../") { + return true + } + for _, seg := range strings.Split(norm, "/") { + if seg == ".." { + return true + } + } + return false +} + func isoID(absPath string) string { h := sha256.Sum256([]byte(absPath)) return hex.EncodeToString(h[:8]) diff --git a/internal/lkgateway/media_zipslip_test.go b/internal/lkgateway/media_zipslip_test.go new file mode 100644 index 0000000..d0c7640 --- /dev/null +++ b/internal/lkgateway/media_zipslip_test.go @@ -0,0 +1,25 @@ +package lkgateway + +import "testing" + +// M3: пути с выходом за корень или абсолютные должны отклоняться перед 7z x. +func TestUnsafeArchivePath(t *testing.T) { + unsafe := []string{ + "../evil.txt", "../../etc/passwd", "a/../../b", "/etc/passwd", + `\windows\system32`, "C:/Windows/x", `dir\..\..\x`, "..", + } + for _, p := range unsafe { + if !unsafeArchivePath(p) { + t.Errorf("должен быть отклонён: %q", p) + } + } + safe := []string{ + "boot/grub/grub.cfg", "isolinux/isolinux.bin", "a/b/c.txt", + "file.iso", "dir/sub/..file", "a..b/c", "x.tar", + } + for _, p := range safe { + if unsafeArchivePath(p) { + t.Errorf("ложное срабатывание на безопасном пути: %q", p) + } + } +} diff --git a/modules/lk/relay-php/index.php b/modules/lk/relay-php/index.php index 94ed3cf..529982e 100644 --- a/modules/lk/relay-php/index.php +++ b/modules/lk/relay-php/index.php @@ -50,6 +50,41 @@ function dataInsideWebroot(): bool return str_starts_with(dataDir(), __DIR__); } +// installTokenFile — файл одноразового «кода установки». Установщик кабинета +// открыт без входа (его ставят в браузере), поэтому чувствительные шаги (сохранить +// связь с ядром, завести администратора) требуют код, который лежит в файле рядом +// с данными: прочитать его можно только имея доступ к файловой системе (FTP, +// файловый менеджер, SSH) — то есть будучи владельцем сервера. Атакующий с одним +// HTTP-доступом код не узнает и настройку/создание админа не выполнит. +function installTokenFile(): string +{ + return dataDir() . '/install.lock'; +} + +// installToken — текущий код установки; создаётся при первом показе установщика. +function installToken(): string +{ + $f = installTokenFile(); + if (is_file($f)) { + return trim((string)@file_get_contents($f)); + } + try { + $tok = bin2hex(random_bytes(16)); + } catch (Throwable $e) { + return ''; + } + @file_put_contents($f, $tok); + @chmod($f, 0600); + return $tok; +} + +// installTokenOK — введённый код совпадает с файлом (постоянное сравнение). +function installTokenOK(): bool +{ + $want = installToken(); + return $want !== '' && hash_equals($want, trim((string)($_POST['itoken'] ?? ''))); +} + /** Настройки: файл config.php — PHP-заглушка + JSON, чтобы даже отданный веб-сервером файл ничего не показал. */ function loadConfig(): array { @@ -505,12 +540,19 @@ function installer(array $cfg): never page('Кабинет уже настроен', '

Установщик закрыт: настройки сохранены в ' . h(dataDir()) . '/config.php. Чтобы изменить их, удалите этот файл и откройте страницу снова.

Перейти ко входу
', $side); } $csrf = csrfToken(); + installToken(); // создаём файл кода при первом показе, чтобы оператор мог его прочитать $error = ''; $notice = ''; $vals = ['title' => $cfg['title'] ?? '', 'mode' => $cfg['mode'] ?? 'direct', 'bj_url' => $cfg['bj_url'] ?? '', 'bj_key' => $cfg['bj_key'] ?? '']; if ($method === 'POST' && !csrfOK()) { $error = 'Форма устарела, обновите страницу.'; + } elseif ($method === 'POST' && ($step === 'connect' || $step === 'admin') && !installTokenOK()) { + // Чувствительный шаг без верного кода установки — не выполняем. + $error = 'Неверный код установки. Откройте файл ' . h(installTokenFile()) . ' через файловый менеджер или FTP и вставьте код из него.'; + if ($step === 'admin') { + adminForm($csrf, '', $error, [['Проверка сервера', true], ['Связь с ядром', true], ['Администратор кабинета', true]], $side); + } } elseif ($method === 'POST' && $step === 'connect') { foreach ($vals as $k => $_) { $vals[$k] = trim((string)($_POST[$k] ?? '')); @@ -521,7 +563,7 @@ function installer(array $cfg): never $error = 'Укажите ключ доступа модуля — он создаётся в настройках ядра, раздел «Кабинет клиента».'; } elseif ($new['mode'] === 'direct' && $new['bj_url'] === '') { $error = 'Укажите адрес ядра или выберите режим «ядро подключается к кабинету».'; - } elseif ($new['mode'] === 'direct' && !str_starts_with($new['bj_url'], 'https://') && !preg_match('~^http://(127\.0\.0\.1|localhost)~', $new['bj_url'])) { + } elseif ($new['mode'] === 'direct' && !str_starts_with($new['bj_url'], 'https://') && !preg_match('~^http://(127\.0\.0\.1|localhost)(:\d+)?(/|$)~', $new['bj_url'])) { $error = 'Адрес ядра должен начинаться с https:// — по этому каналу идут ключ и сессии клиентов.'; } elseif ($new['mode'] === 'agent' && !extension_loaded('pdo_sqlite')) { $error = 'Для режима «ядро подключается к кабинету» нужно расширение sqlite — включите его в панели хостинга или выберите прямой режим.'; @@ -590,13 +632,14 @@ function installer(array $cfg): never . '' . '
' . '

Создаётся в настройках ядра: «Кабинет клиента» → «Создать ключ».

' + . '

Откройте файл ' . h(installTokenFile()) . ' через файловый менеджер хостинга или FTP и вставьте код из него — так мы убеждаемся, что настройку выполняет владелец сервера, а не посторонний из интернета.

' . ''; page('Три шага — и кабинет работает', $f, $side, $steps); } function adminForm(string $csrf, string $notice, string $error, array $steps, string $side): never { - $f = '
' + $f = '' . ($error !== '' ? '
' . h($error) . '
' : '') . ($notice !== '' ? '
' . h($notice) . '
' : '') . '

Администратор кабинета

Сотрудник депозитария, который входит в кабинет, видит список клиентов и может открыть кабинет клиента на просмотр. Учётная запись заводится в ядре. Если администратор уже есть — шаг можно пропустить.

' . '
' @@ -610,6 +653,7 @@ function adminForm(string $csrf, string $notice, string $error, array $steps, st function done(string $side): never { + @unlink(installTokenFile()); // установка завершена — код больше не нужен page('Кабинет установлен', '

Готово

Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Кабинет клиента». Настройки ретранслятора — в ' . h(dataDir()) . '/config.php; чтобы пройти установку заново, удалите его.

Перейти ко входу
', $side); }