Безопасность: код установки ретранслятора (M1), zip-slip 7z (M3), L1/L5/L2

Продолжение исправлений по security-аудиту.

M1 (MEDIUM) — установщик PHP-ретранслятора был открыт без входа: до создания
админа атакующий из интернета мог завести админа кабинета или подменить адрес
ядра/ключ. Добавлен «код установки»: генерируется в файл install.lock рядом с
данными (вне веб-корня), прочитать его можно только с доступом к файловой
системе (FTP/файловый менеджер) — то есть владельцу сервера. Шаги connect и
admin (включая «пропустить») требуют верный код. Регексп loopback заякорен
(`http://127.0.0.1.attacker.com` больше не проходит как loopback). Код удаляется
по завершении установки. Проверено встроенным php-сервером.

M3 (MEDIUM) — распаковка 7z в мастере ключа (ExtractISO) сохраняла пути из
архива → запись «../…» вышла бы за каталог. Перед распаковкой архив
просматривается (7z l -slt, без записи); при абсолютном пути или выходе за
корень распаковка отклоняется. Тест TestUnsafeArchivePath.

L1 — target_device не проверялся перед root mount: значение не из списка
съёмных теперь отклоняется (иначе root смонтировал бы произвольный девайс).
L5 — likeEscape в ручном поиске DEPO (manualDepoSearch): метасимволы LIKE от
пользователя больше не «расползаются» по базе.
L2a — кука сессии ядра получает Secure и за TLS-терминирующим прокси на loopback
(reqIsHTTPS учитывает X-Forwarded-Proto только от loopback).

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:35:38 +03:00
parent d975957335
commit e16d4454ce
6 changed files with 159 additions and 5 deletions
+19 -1
View File
@@ -917,6 +917,24 @@ func clientIP(r *http.Request) string {
return host 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 { func authMiddleware(rc *RuntimeConfig, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path p := r.URL.Path
@@ -1046,7 +1064,7 @@ func registerAuth(mux *http.ServeMux, rc *RuntimeConfig) {
loginFailReset(key) loginFailReset(key)
_ = globalUsers.update(u.Login, func(x *User) { x.LastLogin = time.Now() }) _ = globalUsers.update(u.Login, func(x *User) { x.LastLogin = time.Now() })
s := globalSessions.New(u.Login, clientIP(r), r.UserAgent()) 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)) log.Printf("auth: вход %s (%s, %s) с %s", u.Login, u.Role, u.Source, clientIP(r))
if next == "" || next == "/login" { if next == "" || next == "/login" {
next = u.Home() next = u.Home()
+8 -1
View File
@@ -306,12 +306,19 @@ func (h *setupHandlers) keyWizardInstall(w http.ResponseWriter, r *http.Request)
targetDev := strings.TrimSpace(r.FormValue("target_device")) targetDev := strings.TrimSpace(r.FormValue("target_device"))
targetMnt := "" targetMnt := ""
if targetDev != "" { if targetDev != "" {
found := false
for _, d := range listFlashDrives() { for _, d := range listFlashDrives() {
if d.Device == targetDev { if d.Device == targetDev {
targetMnt = d.Mountpoint targetMnt, found = d.Mountpoint, true
break break
} }
} }
// Устройство должно быть из списка обнаруженных съёмных: иначе root-воркер
// смонтировал бы произвольный блочный девайс/путь, переданный из формы.
if !found {
h.keyWizFlash(w, r, "Выбранное устройство не найдено среди съёмных носителей — обновите список и повторите.")
return
}
} }
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
+3 -1
View File
@@ -71,7 +71,9 @@ func manualDepoSearch(rc *RuntimeConfig, q string) ([]manualDepoFace, string) {
defer db.Close() defer db.Close()
digits := strings.ReplaceAll(q, " ", "") digits := strings.ReplaceAll(q, " ", "")
// Действующая версия карточки (LAST_FLAG=1); счета привязаны к исходной записи (SELF_ID). // Действующая версия карточки (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 { if err != nil {
return nil, "поиск в DEPO: " + err.Error() return nil, "поиск в DEPO: " + err.Error()
} }
+58
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -290,6 +291,16 @@ func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
cctx, cancel := context.WithTimeout(ctx, 5*time.Minute) cctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel() 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<dst> [-p<pass>] <archive> — рекурсивное извлечение. // 7z x -y -o<dst> [-p<pass>] <archive> — рекурсивное извлечение.
args := []string{"x", "-y", "-o" + dst} args := []string{"x", "-y", "-o" + dst}
if password != "" { if password != "" {
@@ -313,6 +324,53 @@ func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
return m, nil 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 { func isoID(absPath string) string {
h := sha256.Sum256([]byte(absPath)) h := sha256.Sum256([]byte(absPath))
return hex.EncodeToString(h[:8]) return hex.EncodeToString(h[:8])
+25
View File
@@ -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)
}
}
}
+46 -2
View File
@@ -50,6 +50,41 @@ function dataInsideWebroot(): bool
return str_starts_with(dataDir(), __DIR__); 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, чтобы даже отданный веб-сервером файл ничего не показал. */ /** Настройки: файл config.php — PHP-заглушка + JSON, чтобы даже отданный веб-сервером файл ничего не показал. */
function loadConfig(): array function loadConfig(): array
{ {
@@ -505,12 +540,19 @@ function installer(array $cfg): never
page('Кабинет уже настроен', '<div class="box"><p class="mut">Установщик закрыт: настройки сохранены в ' . h(dataDir()) . '/config.php. Чтобы изменить их, удалите этот файл и откройте страницу снова.</p><a class="btn" href="/login">Перейти ко входу</a></div>', $side); page('Кабинет уже настроен', '<div class="box"><p class="mut">Установщик закрыт: настройки сохранены в ' . h(dataDir()) . '/config.php. Чтобы изменить их, удалите этот файл и откройте страницу снова.</p><a class="btn" href="/login">Перейти ко входу</a></div>', $side);
} }
$csrf = csrfToken(); $csrf = csrfToken();
installToken(); // создаём файл кода при первом показе, чтобы оператор мог его прочитать
$error = ''; $error = '';
$notice = ''; $notice = '';
$vals = ['title' => $cfg['title'] ?? '', 'mode' => $cfg['mode'] ?? 'direct', 'bj_url' => $cfg['bj_url'] ?? '', 'bj_key' => $cfg['bj_key'] ?? '']; $vals = ['title' => $cfg['title'] ?? '', 'mode' => $cfg['mode'] ?? 'direct', 'bj_url' => $cfg['bj_url'] ?? '', 'bj_key' => $cfg['bj_key'] ?? ''];
if ($method === 'POST' && !csrfOK()) { if ($method === 'POST' && !csrfOK()) {
$error = 'Форма устарела, обновите страницу.'; $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') { } elseif ($method === 'POST' && $step === 'connect') {
foreach ($vals as $k => $_) { foreach ($vals as $k => $_) {
$vals[$k] = trim((string)($_POST[$k] ?? '')); $vals[$k] = trim((string)($_POST[$k] ?? ''));
@@ -521,7 +563,7 @@ function installer(array $cfg): never
$error = 'Укажите ключ доступа модуля — он создаётся в настройках ядра, раздел «Кабинет клиента».'; $error = 'Укажите ключ доступа модуля — он создаётся в настройках ядра, раздел «Кабинет клиента».';
} elseif ($new['mode'] === 'direct' && $new['bj_url'] === '') { } elseif ($new['mode'] === 'direct' && $new['bj_url'] === '') {
$error = 'Укажите адрес ядра или выберите режим «ядро подключается к кабинету».'; $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:// — по этому каналу идут ключ и сессии клиентов.'; $error = 'Адрес ядра должен начинаться с https:// — по этому каналу идут ключ и сессии клиентов.';
} elseif ($new['mode'] === 'agent' && !extension_loaded('pdo_sqlite')) { } elseif ($new['mode'] === 'agent' && !extension_loaded('pdo_sqlite')) {
$error = 'Для режима «ядро подключается к кабинету» нужно расширение sqlite — включите его в панели хостинга или выберите прямой режим.'; $error = 'Для режима «ядро подключается к кабинету» нужно расширение sqlite — включите его в панели хостинга или выберите прямой режим.';
@@ -590,13 +632,14 @@ function installer(array $cfg): never
. '<label class="radio"><input type="radio" name="mode" value="agent"' . ($vals['mode'] === 'agent' ? ' checked' : '') . '> <span><b>Ядро подключается к кабинету</b> — у ядра нет адреса в интернете; оно само дозвонится сюда.</span></label></div>' . '<label class="radio"><input type="radio" name="mode" value="agent"' . ($vals['mode'] === 'agent' ? ' checked' : '') . '> <span><b>Ядро подключается к кабинету</b> — у ядра нет адреса в интернете; оно само дозвонится сюда.</span></label></div>'
. '<div><label class="l">Адрес ядра (только для первого способа)</label><input type="text" name="bj_url" value="' . h($vals['bj_url']) . '" placeholder="https://bj.депозитарий.ru" autocomplete="off"></div>' . '<div><label class="l">Адрес ядра (только для первого способа)</label><input type="text" name="bj_url" value="' . h($vals['bj_url']) . '" placeholder="https://bj.депозитарий.ru" autocomplete="off"></div>'
. '<div><label class="l">Ключ доступа модуля</label><input type="password" name="bj_key" value="' . h($vals['bj_key']) . '" autocomplete="off" placeholder="lk-…"><p class="mut" style="margin-top:6px">Создаётся в настройках ядра: «Кабинет клиента» → «Создать ключ».</p></div>' . '<div><label class="l">Ключ доступа модуля</label><input type="password" name="bj_key" value="' . h($vals['bj_key']) . '" autocomplete="off" placeholder="lk-…"><p class="mut" style="margin-top:6px">Создаётся в настройках ядра: «Кабинет клиента» → «Создать ключ».</p></div>'
. '<div><label class="l">Код установки</label><input type="text" name="itoken" autocomplete="off" placeholder="из файла install.lock"><p class="mut" style="margin-top:6px">Откройте файл <code>' . h(installTokenFile()) . '</code> через файловый менеджер хостинга или FTP и вставьте код из него — так мы убеждаемся, что настройку выполняет владелец сервера, а не посторонний из интернета.</p></div>'
. '<button class="btn" type="submit">Проверить связь и сохранить</button></form>'; . '<button class="btn" type="submit">Проверить связь и сохранить</button></form>';
page('Три шага — и кабинет работает', $f, $side, $steps); page('Три шага — и кабинет работает', $f, $side, $steps);
} }
function adminForm(string $csrf, string $notice, string $error, array $steps, string $side): never function adminForm(string $csrf, string $notice, string $error, array $steps, string $side): never
{ {
$f = '<form method="post" action="/install"><input type="hidden" name="csrf" value="' . h($csrf) . '"><input type="hidden" name="step" value="admin">' $f = '<form method="post" action="/install"><input type="hidden" name="csrf" value="' . h($csrf) . '"><input type="hidden" name="step" value="admin"><input type="hidden" name="itoken" value="' . h(installToken()) . '">'
. ($error !== '' ? '<div class="msg err">' . h($error) . '</div>' : '') . ($notice !== '' ? '<div class="msg ok">' . h($notice) . '</div>' : '') . ($error !== '' ? '<div class="msg err">' . h($error) . '</div>' : '') . ($notice !== '' ? '<div class="msg ok">' . h($notice) . '</div>' : '')
. '<h2>Администратор кабинета</h2><p class="mut">Сотрудник депозитария, который входит в кабинет, видит список клиентов и может открыть кабинет клиента на просмотр. Учётная запись заводится в ядре. Если администратор уже есть — шаг можно пропустить.</p>' . '<h2>Администратор кабинета</h2><p class="mut">Сотрудник депозитария, который входит в кабинет, видит список клиентов и может открыть кабинет клиента на просмотр. Учётная запись заводится в ядре. Если администратор уже есть — шаг можно пропустить.</p>'
. '<div><label class="l">Логин</label><input type="text" name="login" autocomplete="off"></div>' . '<div><label class="l">Логин</label><input type="text" name="login" autocomplete="off"></div>'
@@ -610,6 +653,7 @@ function adminForm(string $csrf, string $notice, string $error, array $steps, st
function done(string $side): never function done(string $side): never
{ {
@unlink(installTokenFile()); // установка завершена — код больше не нужен
page('Кабинет установлен', '<div class="box"><h2>Готово</h2><p class="mut">Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Кабинет клиента». Настройки ретранслятора — в ' . h(dataDir()) . '/config.php; чтобы пройти установку заново, удалите его.</p><a class="btn" href="/login">Перейти ко входу</a></div>', $side); page('Кабинет установлен', '<div class="box"><h2>Готово</h2><p class="mut">Клиенты входят по логину и паролю, которые выдаёт депозитарий. Учётные записи заводятся в настройках ядра, раздел «Кабинет клиента». Настройки ретранслятора — в ' . h(dataDir()) . '/config.php; чтобы пройти установку заново, удалите его.</p><a class="btn" href="/login">Перейти ко входу</a></div>', $side);
} }