20. Безопасность БД

Введение: Многоуровневая защита и нулевое доверие

В старой фортификационной архитектуре средневековые замки строились по принципу концентрических колец: глубокий ров, внешняя крепостная стена, внутренние башни и, наконец, цитадель — донжон. Если враг пробивал внешние ворота, он оказывался в узком каменном коридоре под перекрестным обстрелом лучников. В компьютерной безопасности мы слишком долго верили в иллюзию «неприступного внешнего периметра»: дескать, если сервис находится за корпоративным VPN или закрытым контуром Kubernetes, то внутри все свои и можно передавать пароли открытым текстом, подключаться к базе под пользователем postgres или root и пренебрегать шифрованием сетевого трафика.

Современная инженерная парадигма нулевого доверия (Zero Trust) беспощадно отбрасывает эту наивность. Мы обязаны проектировать архитектуру, исходя из постулата: внешний периметр уже скомпрометирован, сеть прослушивается злоумышленником, а любой соседний микросервис может быть захвачен.

База данных — это сокровищница и последний рубеж обороны любого бизнеса. Если скомпрометирован веб-сервер, его можно изолировать и пересоздать за секунды; если украдена или уничтожена производственная база данных — бизнес перестает существовать. Безопасность баз данных — это не галочка в конфигурационном файле, а сквозная инженерная дисциплина, связывающая криптографические инструкции центрального процессора (AES-NI), управление кучей в рантайме Go, системные вызовы ядра Linux и внутренние механизмы СУБД (Row-Level Security, RBAC).

В этой главе мы разберем:

  • Управление секретами и механику аутентификации в рантайме Go без утечек в дампы памяти (/proc/<pid>/mem).
  • Шифрование в транзите: TLS 1.3, взаимная аутентификация mTLS, сессионные тикеты и аппаратное ускорение CPU.
  • Защиту от инъекций на уровне грамматического разбора СУБД: почему серверные подготовленные выражения (PREPARE + EXECUTE) фундаментально защищают систему, а строковая конкатенация разрушает абстракцию кода и данных.
  • Принцип наименьших привилегий, ролевой доступ (RBAC) и изоляцию строк (Row-Level Security).
  • Взаимодействие криптографии с Garbage Collector: очистку памяти, постоянное время сравнения (crypto/subtle) и защиту от timing-атак.
  • Аудит (pgaudit), маскирование данных и безопасное логирование без просадки дискового IOPS.
flowchart TD
    Client["Клиентское приложение"] -->|"TLS 1.3 handshake"| Proxy["Балансировщик или API Gateway"]
    Proxy -->|"mTLS верификация и сертификат"| App["Go сервис и пул database/sql"]
    App -->|"Аутентификация scram или пароль"| DB["СУБД PostgreSQL или MySQL"]
    DB -->|"RLS и контроль привилегий на уровне строк"| Storage["Шифрование на диске и в кэше страниц"]

    classDef client fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc;
    classDef proxy fill:#0f172a,stroke:#f59e0b,stroke-width:1px,color:#f8fafc;
    classDef app fill:#1e293b,stroke:#818cf8,stroke-width:1px,color:#f8fafc;
    classDef db fill:#1e293b,stroke:#34d399,stroke-width:1px,color:#f8fafc;
    class Client client;
    class Proxy proxy;
    class App app;
    class DB,Storage db;
  

  1. Управление секретами и аутентификация в рантайме Go

Строка подключения (DSN — Data Source Name) содержит конфиденциальные учетные данные: логин, пароль, хост и параметры шифрования. В Go строка — это неизменяемый заголовок reflect.StringHeader, указывающий на массив байт в куче (heap). Сборщик мусора (GC), освобождая память, не затирает ее нулями. Байт-буфер с паролем может часами оставаться в физической оперативной памяти процесса. Это создает реальный вектор атаки через инспекцию адресного пространства процесса /proc/<pid>/mem, аварийные дампы ядра (core-dumps) или аллокации в shared memory.

При подключении к базе данных придерживайтесь строгих правил изоляции:

Go
package database

import (
    "database/sql"
    "fmt"
    "log"
    "os"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func OpenSecureDB() (*sql.DB, error) {
    // ❌ Опасно: жестко закодированные креды или передача из env без контроля
    // dsn := fmt.Sprintf("postgres://admin:SuperSecret123@db:5432/prod")

    // ✅ Безопасно: загрузка из защищенного источника (Vault, Kubernetes Secrets), валидация, быстрый сброс
    password := os.Getenv("DB_PASSWORD")
    if password == "" {
        return nil, fmt.Errorf("DB_PASSWORD not set")
    }

    // Явное указание строгого режима проверки сертификатов и таймаутов
    dsn := fmt.Sprintf(
        "postgres://app_user:%s@db-prod.internal:5432/appdb?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca.crt&connect_timeout=5",
        password,
    )

    // После создания пула переменную рекомендуется очистить (хотя это не панацея в Go)
    // password = "" // Сборщик мусора позже освободит память, но содержимое может остаться в куче

    db, err := sql.Open("pgx", dsn)
    if err != nil {
        return nil, fmt.Errorf("open db: %w", err)
    }

    // Ограничиваем время ожидания установления соединения
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    // Обязательно проверяем соединение с таймаутом, чтобы не держать висящие горутины
    if err := db.PingContext(ctx); err != nil {
        db.Close()
        return nil, fmt.Errorf("ping db: %w", err)
    }

    return db, nil
}

  1. Шифрование в транзите: TLS 1.3, mTLS и аппаратное ускорение

Шифрование сетевого трафика между микросервисом и СУБД обязательно даже внутри изолированного частного облака. Современный промышленный стандарт — протокол TLS 1.3. В сравнении с TLS 1.2 он устранил устаревшие небезопасные шифры, сократил фазу рукопожатия (Handshake) до одного кругового путешествия пакетов (1 RTT, либо 0 RTT при повторном подключении) и стандартизировал алгоритмы аутентифицированного шифрования с присоединенными данными (AEAD).

В Go конфигурация шифрования управляется структурой tls.Config из пакета crypto/tls. При использовании пула database/sql или pgxpool TLS-рукопожатие выполняется только один раз при установке физического TCP-сокета, после чего шифрованный канал переиспользуется сотнями горутин:

Go
package database

import (
    "crypto/tls"
    "crypto/x509"
    "database/sql"
    "fmt"
    "os"
    "time"

    "github.com/lib/pq"
)

func setupTLS(dsn string) (*sql.DB, error) {
    rootCAs, _ := x509.SystemCertPool()
    if rootCAs == nil {
        rootCAs = x509.NewCertPool()
    }

    // Загрузка доверенного корневого сертификата CA базы данных
    certs, err := os.ReadFile("/etc/ssl/certs/db-ca.pem")
    if err != nil {
        return nil, fmt.Errorf("read ca cert: %w", err)
    }
    if ok := rootCAs.AppendCertsFromPEM(certs); !ok {
        return nil, fmt.Errorf("failed to append CA cert")
    }

    tlsConfig := &tls.Config{
        RootCAs:            rootCAs,
        MinVersion:         tls.VersionTLS12, // TLS 1.3 предпочтительнее, но 1.2 часто требуется для совместимости
        CipherSuites: []uint16{
            tls.TLS_AES_128_GCM_SHA256,
            tls.TLS_CHACHA20_POLY1305_SHA256,
        },
        SessionTicketsDisabled: false, // Ресумпшн сессий снижает CPU нагрузку при реконнектах
    }

    // Регистрация кастомной конфигурации в драйвере lib/pq
    pq.RegisterTLSConfig("secure", tlsConfig)

    dsnWithTLS := dsn + "&sslmode=verify-full&sslinline=true&tlsconfig=secure"
    return sql.Open("postgres", dsnWithTLS)
}

  1. SQL-инъекции: Парсеры, подготовленные выражения и риски ORM

SQL-инъекция возникает тогда, когда пользовательские входные данные ошибочно интерпретируются грамматическим парсером СУБД как управляющие инструкции SQL. Строковая конкатенация разрушает границу между кодом и данными.

Единственная математически надежная защита — серверные подготовленные выражения (Server-side Prepared Statements): вызовы PREPARE и EXECUTE. При вызове PREPARE СУБД выполняет лексический и синтаксический анализ SQL-текста, строит абстрактное синтаксическое дерево (AST), формирует план выполнения запроса и компилирует его в бинарную форму. При последующем вызове EXECUTE аргументы передаются в бинарном формате протокола и подставляются в готовые слоты плана. Никакие специальные символы (', ;, --) в аргументах не могут изменить структуру скомпилированного AST!

Go
// ❌ Критически опасно: конкатенация или форматирование строк
query := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", userInput)
rows, err := db.QueryContext(ctx, query) // user может передать: ' OR 1=1 --

// ✅ Безопасно: параметризация через $1, $2 (передача данных отдельно от AST)
query := "SELECT id, name FROM users WHERE email = $1 AND status = $2"
rows, err := db.QueryContext(ctx, query, userInput, "active")

Подробный анализ всех векторов инъекций и защиты от них мы разберем в отдельной главе: 21. SQL Injection.


  1. Принцип наименьших привилегий и изоляция на уровне СУБД

Архитектурный грех номер один — подключение приложения к СУБД под учетной записью администратора (SUPERUSER, postgres, root). В этом случае любая уязвимость в коде позволяет атакующему читать системные файлы ОС (COPY ... FROM PROGRAM), вызывать внешние библиотеки C или стереть всю базу данных командой DROP DATABASE.

Принцип наименьших привилегий (Principle of Least Privilege) требует гранулярного разграничения прав:

  • Разделение ролей по обязанностям:
    • app_reader — доступ только к операциям чтения (SELECT).
    • app_writer — доступ к модификации данных (INSERT, UPDATE, DELETE).
    • app_migrator — роль с правами DDL (CREATE, ALTER, DROP), используемая исключительно в процессе CI/CD деплоя и запуска миграций (4. Миграции базы данных). Приложение в runtime не должно иметь прав DDL!
  • Row-Level Security (RLS): Включение механизма изоляции строк командой ALTER TABLE ... ENABLE ROW LEVEL SECURITY. СУБД на уровне ядра принудительно фильтрует возвращаемые и обновляемые строки на основе контекста текущей сессии (current_user или переменной сессии tenant_id). Это исключает утечку данных между арендаторами в мультитенантных системах даже при ошибке в условии WHERE в коде Go!
  • Изоляция схем (Schema Isolation): Настройка параметра search_path и распределение таблиц по изолированным схемам для разных сервисов исключает доступ к таблицам платежей из сервиса комментариев.

  1. Механика памяти, GC и криптографические примитивы

В Go рантайм берет на себя управление памятью, что создает скрытые угрозы для криптографических секретов:

  1. Timing-атаки при сравнении токенов: Стандартный оператор == или функция bytes.Equal прерывают выполнение на первом несовпадающем байте. Если первый байт не совпал, функция завершается за 2 такта CPU; если не совпал 16-й байт — за 20 тактов. Измеряя сетевые наносекундные задержки (Timing Attack), злоумышленник может посимвольно подобрать секретный токен или сигнатуру HMAC!

    В Go для любых криптографических проверок обязательно используется пакет crypto/subtle:

    Go
    package security
    
    import "crypto/subtle"
    
    func VerifyToken(input, expected []byte) bool {
        // subtle.ConstantTimeCompare выполняется за строго фиксированное число тактов CPU
        // независимо от содержимого и позиции первого несовпадающего байта
        return subtle.ConstantTimeCompare(input, expected) == 1
    }
    
  2. Очистка секретов в ОЗУ: Сборщик мусора Go перемещает объекты, создавая их копии в куче. Если в коде хранится закрытый ключ или пароль в виде слайса []byte, после использования его необходимо немедленно перезаписать нулями (for i := range secret { secret[i] = 0 }).

  3. Запрет своппинга: В Linux для высокочувствительных данных рассмотрите mlock() через unix.Mlock, чтобы заблокировать страницы в RAM и запретить своппинг на диск.
  4. Переиспользование буферов: Использование пула sync.Pool снижает частоту выделения памяти под криптографические операции и ограничивает размазывание секретов по куче.

  1. Аудит, маскирование и безопасное логирование

Журналирование операций — обязательное требование регуляторов (PCI DSS, HIPAA, GDPR). В СУБД PostgreSQL для этого используется расширение pgaudit или системные таблицы pg_log. Однако аудит создает значительный I/O-оверхед, так как каждое событие записывается в журнал синхронно.

Однако на стороне Go-сервиса необходимо соблюдать гигиену логирования:

  • Никаких паролей, токенов сессий и номеров кредитных карт в логах!
  • Использование структурированного логгера log/slog с явным разделением уровней: slog.LevelInfo для бизнес-событий, slog.LevelDebug только для локальной отладки.
  • Обязательное маскирование персональных данных (PII): card_number=************4242.
Go
package logging

import (
    "context"
    "log/slog"
    "strings"
)

func LogQueryStart(ctx context.Context, logger *slog.Logger, query string) {
    // Безопасное логирование метаинформации без тела запроса и аргументов
    queryType := extractQueryType(query)

    correlationID, _ := ctx.Value("request_id").(string)

    logger.InfoContext(ctx, "database transaction initiated",
        slog.String("query_type", queryType),
        slog.String("correlation_id", correlationID),
    )
}

func extractQueryType(q string) string {
    parts := strings.Fields(strings.TrimSpace(q))
    if len(parts) > 0 {
        return strings.ToUpper(parts[0])
    }
    return "UNKNOWN"
}

  1. Ловушки, антипаттерны и вопросы с собеседований

Разберем распространенные архитектурные ловушки:

  1. TLS в пуле соединений:
    • Проблема: Разработчик настраивает tls.Config для database/sql, но драйвер создает новое соединение при каждом Open, а пул не переиспользует TLS-контекст правильно. Не пересоздавайте *tls.Config при каждом запросе.
    • Решение: Убедитесь, что конфигурация TLS инициализируется один раз при старте приложения и переиспользуется пулом соединений (современные pgx и lib/pq делают это). Не пересоздавайте *tls.Config при каждом запросе.
  2. Хранение хешей паролей:
    • Проблема: Приложение сохраняет пароли с использованием устаревших алгоритмов MD5 или SHA1, либо отправляет пароль в базу данных в открытом виде для сверки там.
    • Решение: Используйте стойкие функции деривации ключей: Argon2id или bcrypt. Для аутентификации самого приложения к PostgreSQL используйте метод SCRAM-SHA-256, исключающий передачу пароля в открытом виде даже при компрометации TLS.
  3. Уязвимости в ORM:
    • Проблема: Использование ORM создает ложное чувство защищенности. Метод .Where("name = ?", userInput) безопасен, однако конкатенация вида .Where(fmt.Sprintf("name = '%s'", userInput)) по-прежнему уязвима для инъекций.
    • Решение: Внедрите в конвейер CI/CD анализатор статической безопасности gosec. Он автоматически находит подозрительные конкатенации строк перед вызовами QueryContext и методами ORM.
  4. Сравнение с другими экосистемами:
    • Java: Традиционно использует JDBC с тяжелыми пулами (HikariCP). Настройка TLS выполняется через свойства JVM или SSLContext. Экосистема богата готовыми решениями, но отличается высоким потреблением памяти.
    • .NET / C#: Работает через System.Data.SqlClient и Npgsql, отлично интегрируется с облачной технологией Always Encrypted и Azure Key Vault.
    • Go: Предоставляет прозрачный, низкоуровневый контроль над crypto/tls и пулами соединений. Отсутствие скрытой фоновой магии требует от инженера явного написания кода, но взамен гарантирует предсказуемое потребление памяти, отсутствие неконтролируемых пауз и исключительную надежность.

Итог

Безопасность баз данных в Go — это многоуровневая инженерная крепость. Она строится не на надежде на внешний периметр, а на бескомпромиссных технических гарантиях:

  • Шифрование в транзите: TLS 1.3 с проверкой сертификатов (sslmode=verify-full), защитой сессий и аппаратным ускорением AES-NI.
  • Математическое разделение кода и данных: Строгое использование подготовленных выражений (PREPARE / EXECUTE) и белых списков для динамических идентификаторов.
  • Минимальные привилегии: Отдельные системные роли СУБД для чтения, записи и миграций, изоляция схем и Row-Level Security.
  • Криптографическая гигиена в памяти: Использование crypto/subtle, быстрый сброс секретов и контроль за нагрузкой от хэширования паролей.
  • Осознанный аудит: Направленный сбор логов критических таблиц без убийства дискового ввода-вывода СУБД.

Мы заложили прочный фундамент общей безопасности. Теперь пришло время погрузиться в анатомию самой коварной и разрушительной угрозы реляционного мира — SQL-инъекций. В следующей лекции мы детально изучим механику синтаксического разбора запросов и разберем все сценарии атак: 21. SQL Injection.