e16d4454ce
Продолжение исправлений по 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
408 lines
14 KiB
Go
408 lines
14 KiB
Go
package lkgateway
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/asn1"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// MediaRoot — где bj-server хранит свои носители: распакованные ISO,
|
|
// импортированные ключевые контейнеры. На прод-машине пользователь bj
|
|
// должен быть владельцем этой директории (создаётся install.sh).
|
|
const (
|
|
mediaRoot = "/var/lib/bj/media"
|
|
mediaISODir = "/var/lib/bj/media/iso"
|
|
containersDir = "/var/lib/bj/containers"
|
|
profilesDir = "/var/lib/bj/profiles"
|
|
keyFileMinPerDir = 2 // считаем директорию контейнером, если в ней >= 2 *.key файлов
|
|
)
|
|
|
|
// Medium — один носитель: USB-флешка или распакованная ISO.
|
|
type Medium struct {
|
|
// ID — стабильный идентификатор (для USB — sha1 от пути монтирования,
|
|
// для ISO — sha256-prefix от исходного файла).
|
|
ID string `json:"id"`
|
|
// Kind — "usb" или "iso".
|
|
Kind string `json:"kind"`
|
|
// Mountpoint — корень, по которому сейчас доступен носитель.
|
|
Mountpoint string `json:"mountpoint"`
|
|
// Source — для ISO: путь до исходного .iso на сервере.
|
|
Source string `json:"source,omitempty"`
|
|
// Profile — полный профиль Валидаты (pse + gdbm + vdkeys), если найден.
|
|
Profile *ValidataProfile `json:"profile,omitempty"`
|
|
// Containers — найденные ключевые контейнеры (директории с *.key/*.vdk).
|
|
Containers []KeyContainer `json:"containers"`
|
|
// Certificates — отдельно лежащие сертификаты (.cer/.crt/.pem/.pfx/.p12).
|
|
Certificates []CertFile `json:"certificates"`
|
|
}
|
|
|
|
// ValidataProfile — полный профиль АПК «Валидата Клиент L»: ПСП (.pse),
|
|
// ЛСП (.gdbm) и ключи (vdkeys/*.vdk).
|
|
type ValidataProfile struct {
|
|
Root string `json:"root"` // mountpoint, где найден профиль
|
|
PSEFiles []string `json:"pse_files"` // относительные пути до .pse
|
|
GDBMFiles []string `json:"gdbm_files"` // относительные пути до .gdbm
|
|
KeyFiles []string `json:"key_files"` // относительные пути до .vdk
|
|
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/profiles/
|
|
}
|
|
|
|
// KeyContainer — ключевой контейнер: директория с *.key или *.vdk.
|
|
type KeyContainer struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"` // имя последней компоненты пути
|
|
Files []string `json:"files"` // имена файлов в контейнере
|
|
Imported bool `json:"imported"` // уже скопирован в /var/lib/bj/containers/
|
|
}
|
|
|
|
// CertFile — публичный или PKCS#12 сертификат.
|
|
type CertFile struct {
|
|
Path string `json:"path"`
|
|
Name string `json:"name"`
|
|
Format string `json:"format"` // "cer" | "pem" | "pfx"
|
|
SubjectCN string `json:"subject_cn"`
|
|
IssuerCN string `json:"issuer_cn"`
|
|
Serial string `json:"serial"`
|
|
NotBefore time.Time `json:"not_before"`
|
|
NotAfter time.Time `json:"not_after"`
|
|
INN string `json:"inn,omitempty"`
|
|
HasPrivateKey bool `json:"has_private_key"` // true для .pfx/.p12
|
|
ParseError string `json:"parse_error,omitempty"`
|
|
}
|
|
|
|
// scanMountpoint сканирует точку монтирования на 3 уровня вглубь.
|
|
func scanMountpoint(kind, mountpoint, source string) Medium {
|
|
m := Medium{
|
|
ID: sha1Path(mountpoint),
|
|
Kind: kind,
|
|
Mountpoint: mountpoint,
|
|
Source: source,
|
|
}
|
|
containers, certs, profile := walkForArtifacts(mountpoint)
|
|
m.Containers = containers
|
|
m.Certificates = certs
|
|
m.Profile = profile
|
|
// Отмечаем контейнеры, уже импортированные в /var/lib/bj/containers/.
|
|
for i := range m.Containers {
|
|
if _, err := os.Stat(filepath.Join(containersDir, m.Containers[i].Name)); err == nil {
|
|
m.Containers[i].Imported = true
|
|
}
|
|
}
|
|
// Профиль помечается импортированным, если в /var/lib/bj/profiles/
|
|
// есть директория с тем же именем (имя берётся от носителя).
|
|
if m.Profile != nil {
|
|
name := filepath.Base(mountpoint)
|
|
if _, err := os.Stat(filepath.Join(profilesDir, name)); err == nil {
|
|
m.Profile.Imported = true
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// walkForArtifacts проходит дерево mountpoint (до 3 уровней) и собирает:
|
|
// - директории-контейнеры (>=2 *.key или >=1 *.vdk файла);
|
|
// - отдельные сертификаты (.cer/.pfx/...);
|
|
// - полный профиль Валидаты (наличие *.pse + *.gdbm + *.vdk в дереве).
|
|
func walkForArtifacts(root string) ([]KeyContainer, []CertFile, *ValidataProfile) {
|
|
var containers []KeyContainer
|
|
var certs []CertFile
|
|
prof := &ValidataProfile{Root: root}
|
|
|
|
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(root, p)
|
|
depth := strings.Count(rel, string(filepath.Separator))
|
|
if depth > 4 {
|
|
return filepath.SkipDir
|
|
}
|
|
if info.IsDir() {
|
|
if p != root {
|
|
if c, ok := classifyContainer(p); ok {
|
|
containers = append(containers, c)
|
|
// НЕ делаем SkipDir: внутри vdkeys/ нужно собрать
|
|
// .vdk-файлы для определения профиля Валидаты.
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
lower := strings.ToLower(info.Name())
|
|
switch {
|
|
case strings.HasSuffix(lower, ".pse"):
|
|
prof.PSEFiles = append(prof.PSEFiles, rel)
|
|
case strings.HasSuffix(lower, ".gdbm"):
|
|
prof.GDBMFiles = append(prof.GDBMFiles, rel)
|
|
case strings.HasSuffix(lower, ".vdk"):
|
|
prof.KeyFiles = append(prof.KeyFiles, rel)
|
|
default:
|
|
if cert := classifyCertFile(p); cert != nil {
|
|
certs = append(certs, *cert)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Профилем считаем носитель если есть и pse, и vdk (gdbm
|
|
// опционален — но обычно тоже присутствует).
|
|
if len(prof.PSEFiles) == 0 || len(prof.KeyFiles) == 0 {
|
|
prof = nil
|
|
}
|
|
return containers, certs, prof
|
|
}
|
|
|
|
// classifyContainer — директория является ключевым контейнером, если:
|
|
// - в ней >=2 файлов *.key (старый формат КриптоПро/Валидата); или
|
|
// - в ней >=1 файл *.vdk (Валидата Linux).
|
|
func classifyContainer(dir string) (KeyContainer, bool) {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return KeyContainer{}, false
|
|
}
|
|
var keyFiles, vdkFiles, allFiles []string
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
allFiles = append(allFiles, name)
|
|
lower := strings.ToLower(name)
|
|
switch {
|
|
case strings.HasSuffix(lower, ".vdk"):
|
|
vdkFiles = append(vdkFiles, name)
|
|
case strings.HasSuffix(lower, ".key"):
|
|
keyFiles = append(keyFiles, name)
|
|
}
|
|
}
|
|
if len(vdkFiles) == 0 && len(keyFiles) < keyFileMinPerDir {
|
|
return KeyContainer{}, false
|
|
}
|
|
return KeyContainer{
|
|
Path: dir,
|
|
Name: filepath.Base(dir),
|
|
Files: allFiles,
|
|
}, true
|
|
}
|
|
|
|
// classifyCertFile парсит один файл — возвращает CertFile если это
|
|
// похоже на сертификат.
|
|
func classifyCertFile(path string) *CertFile {
|
|
lower := strings.ToLower(path)
|
|
var format string
|
|
switch {
|
|
case strings.HasSuffix(lower, ".cer"), strings.HasSuffix(lower, ".crt"):
|
|
format = "cer"
|
|
case strings.HasSuffix(lower, ".pem"):
|
|
format = "pem"
|
|
case strings.HasSuffix(lower, ".pfx"), strings.HasSuffix(lower, ".p12"):
|
|
format = "pfx"
|
|
default:
|
|
return nil
|
|
}
|
|
cf := &CertFile{
|
|
Path: path,
|
|
Name: filepath.Base(path),
|
|
Format: format,
|
|
}
|
|
if format == "pfx" {
|
|
// PKCS#12 шифрован PIN'ом — мета без него не вытащить.
|
|
cf.HasPrivateKey = true
|
|
return cf
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
cf.ParseError = "read: " + err.Error()
|
|
return cf
|
|
}
|
|
if len(data) > 32*1024 {
|
|
// Странно большой файл для сертификата — режем.
|
|
data = data[:32*1024]
|
|
}
|
|
der := data
|
|
if block, _ := pem.Decode(data); block != nil && block.Type == "CERTIFICATE" {
|
|
der = block.Bytes
|
|
}
|
|
cert, err := x509.ParseCertificate(der)
|
|
if err != nil {
|
|
cf.ParseError = "x509: " + err.Error()
|
|
return cf
|
|
}
|
|
cf.SubjectCN = cert.Subject.CommonName
|
|
cf.IssuerCN = cert.Issuer.CommonName
|
|
cf.Serial = cert.SerialNumber.Text(16)
|
|
cf.NotBefore = cert.NotBefore
|
|
cf.NotAfter = cert.NotAfter
|
|
cf.INN = extractCertINN(cert)
|
|
return cf
|
|
}
|
|
|
|
// extractCertINN — ИНН из OID 1.2.643.3.131.1.1 в Subject.
|
|
func extractCertINN(c *x509.Certificate) string {
|
|
innOID := asn1.ObjectIdentifier{1, 2, 643, 3, 131, 1, 1}
|
|
for _, name := range c.Subject.Names {
|
|
if name.Type.Equal(innOID) {
|
|
if s, ok := name.Value.(string); ok {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ExtractISO распаковывает образ диска (.iso/.img/.zip и т.п.) в
|
|
// /var/lib/bj/media/iso/<id>/ через 7z. password — опциональный пароль
|
|
// архива (пустая строка = без пароля). id — sha256-prefix от исходного
|
|
// пути. Возвращает Medium или ошибку.
|
|
func ExtractISO(ctx context.Context, isoPath, password string) (Medium, error) {
|
|
abs, err := filepath.Abs(isoPath)
|
|
if err != nil {
|
|
return Medium{}, fmt.Errorf("ISO путь: %w", err)
|
|
}
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
return Medium{}, fmt.Errorf("ISO не найден: %w", err)
|
|
}
|
|
if info.IsDir() {
|
|
return Medium{}, errors.New("ISO путь — это директория, нужен файл")
|
|
}
|
|
|
|
id := isoID(abs)
|
|
dst := filepath.Join(mediaISODir, id)
|
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
|
return Medium{}, fmt.Errorf("создать %s: %w", dst, err)
|
|
}
|
|
if isEmpty, _ := dirEmpty(dst); !isEmpty {
|
|
// Уже распакован раньше — просто пересканируем.
|
|
writeISOSource(id, abs)
|
|
m := scanMountpoint("iso", dst, abs)
|
|
m.ID = id
|
|
return m, nil
|
|
}
|
|
|
|
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<dst> [-p<pass>] <archive> — рекурсивное извлечение.
|
|
args := []string{"x", "-y", "-o" + dst}
|
|
if password != "" {
|
|
// 7z требует пароль через -p без пробела.
|
|
args = append(args, "-p"+password)
|
|
} else {
|
|
// -p- запрещает интерактивный запрос пароля (нам нечего вводить).
|
|
args = append(args, "-p-")
|
|
}
|
|
args = append(args, abs)
|
|
cmd := exec.CommandContext(cctx, "7z", args...)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
_ = os.RemoveAll(dst)
|
|
return Medium{}, fmt.Errorf("7z x: %w / %s", err, strings.TrimSpace(string(out)))
|
|
}
|
|
writeISOSource(id, abs)
|
|
|
|
m := scanMountpoint("iso", dst, abs)
|
|
m.ID = id
|
|
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])
|
|
}
|
|
|
|
func sha1Path(s string) string {
|
|
h := sha256.Sum256([]byte(s))
|
|
return hex.EncodeToString(h[:6])
|
|
}
|
|
|
|
func dirEmpty(path string) (bool, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
defer f.Close()
|
|
names, err := f.Readdirnames(1)
|
|
if errors.Is(err, os.ErrInvalid) || err != nil {
|
|
return len(names) == 0, nil
|
|
}
|
|
return len(names) == 0, nil
|
|
}
|
|
|
|
func readISOSource(id string) string {
|
|
data, err := os.ReadFile(filepath.Join(mediaISODir, id+".src"))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func writeISOSource(id, src string) {
|
|
_ = os.WriteFile(filepath.Join(mediaISODir, id+".src"), []byte(src), 0o644)
|
|
}
|