Как защитить PostgreSQL 16: три слоя между базой и интернетом

9 минут

Базу данных ломают скучно: открытый порт, стандартный логин, подобранный пароль — и пустые логи, в которых некому это заметить. Защита от такого сценария — не один флаг, а три независимых слоя: контроль доступа, шифрование и аудит.

База — не сайт: её вообще не должно быть видно

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

Боты сканируют весь адресный диапазон интернета непрерывно: свежеподнятый Postgres с открытым портом находят за часы, а не за месяцы. Защита от этого сценария — не один флаг, а три независимых слоя: контроль доступа (кто и откуда может подключиться), шифрование (что видно по пути) и аудит (что происходило и кого пора забанить). Ниже — все три на примере PostgreSQL 16.

Слой 1. Кто и откуда: pg_hba.conf

Файл pg_hba.conf — это список правил аутентификации. Правила читаются сверху вниз до первого совпадения, поэтому порядок строк — часть конфигурации, а не оформление. Самая опасная строка выглядит безобидно:

pg_hba.conf: так делать нельзя
host all all 0.0.0.0/0 trust

trust означает «пускать без пароля», а 0.0.0.0/0 — «кого угодно». Такая строка иногда появляется «на минуту, чтобы отладить» — и остаётся. Правильная конфигурация строится наоборот: сначала специфичные разрешения, в конце — явный отказ всем остальным.

pg_hba.conf: рабочая конфигурация
# TYPE    DATABASE   USER       ADDRESS          METHOD

# Приложение работает под ОС-пользователем www-data, а в базу ходит
# как app_user — имена не совпадают, поэтому peer не подойдёт.
# Более специфичное правило стоит ПЕРВЫМ.
local     all        app_user                    scram-sha-256

# Остальные локальные подключения через Unix-сокет — peer:
# роль в базе сверяется с именем пользователя ОС.
local     all        all                         peer

# Сетевые подключения: только по TLS и только из доверенной подсети.
hostssl   all        all        192.168.1.0/24   scram-sha-256

# Всё остальное — явный отказ. Обе версии протокола IP.
host      all        all        0.0.0.0/0        reject
host      all        all        ::0/0            reject

Три правила, на которых это держится:

Метод — только scram-sha-256. Старый md5 уязвим к краже и повтору хэша, trust — не метод аутентификации вообще. С 14-й версии scram и так метод по умолчанию, но в конфигах, переживших несколько апгрейдов, md5 дожил до наших дней.

Сетевой тип — hostssl, а не host: он отклоняет подключения без TLS ещё до проверки пароля.

Последняя строка — reject. Даже если фаервол однажды откроют шире, чем задумано, база откажет сама. Слои защиты для того и нужны, чтобы ошибка в одном не открывала всё.

В postgresql.conf базу стоит привязать к внутреннему адресу — тогда наружу она не слушает вовсе:

postgresql.conf: адрес и пароли
listen_addresses = '192.168.1.100'
password_encryption = scram-sha-256
scram_iterations = 4096   # значение по умолчанию, можно поднять

Изменения pg_hba.conf применяются без перезапуска — SELECT pg_reload_conf(); — а вот смена listen_addresses требует рестарта СУБД. Проверить, что правила прочитались без ошибок, можно прямо из базы — в 16-й версии у представления появились номер правила и имя файла:

Проверка правил после перезагрузки конфига
SELECT rule_number, type, database, user_name, address, auth_method, error
FROM pg_hba_file_rules;

Непустая колонка error — это правило, которое молча не работает. Заодно в 16-й версии правила можно раскладывать по отдельным файлам через include_dir — удобно, когда конфиг собирается автоматикой.

Слой 2. Что видно по пути: TLS

Трафик между приложением и базой без шифрования читается любым узлом на маршруте — вместе с паролями и данными. Серверная часть включается несколькими строками:

postgresql.conf: TLS на сервере
ssl = on
ssl_cert_file = '/etc/postgresql/ssl/server.crt'
ssl_key_file  = '/etc/postgresql/ssl/server.key'
ssl_ca_file   = '/etc/postgresql/ssl/root.crt'   # нужен для клиентских сертификатов
ssl_min_protocol_version = 'TLSv1.2'
ssl_prefer_server_ciphers = on

Права на приватный ключ — не формальность: с ключом, доступным группе или всем, PostgreSQL просто не запустится.

Права на ключ
chmod 600 /etc/postgresql/ssl/server.key
chown postgres:postgres /etc/postgresql/ssl/server.key

Вторая половина настройки — клиентская, и о ней забывают чаще. По умолчанию клиент подключается с sslmode=prefer: шифрование включится, если сервер предложит, но подлинность сервера никто не проверит — подменивший сервер узел получит и пароль, и данные. Честное подключение выглядит так:

Подключение, защищённое от подмены сервера
psql "host=db.internal dbname=app user=app_user \
      sslmode=verify-full \
      sslrootcert=/etc/ssl/certs/internal-ca.crt \
      require_auth=scram-sha-256"

sslmode=verify-full проверяет и подпись центра сертификации, и совпадение имени хоста. Параметр require_auth появился в 16-й версии и закрывает последнюю щель: сервер-самозванец не сможет предложить клиенту метод попроще и выманить пароль открытым текстом.

Слой 3. Что происходило: логи и Fail2ban

Перебор паролей не оставляет следов ровно до тех пор, пока логи не настроены. Включаем сбор и — главное — добавляем в префикс строки %r: без него в логе не будет IP-адреса клиента, и банить будет некого.

postgresql.conf: логирование
logging_collector = on
log_directory = '/var/log/postgresql'
log_filename = 'postgresql-%Y-%m-%d.log'
log_rotation_age = 1d
log_rotation_size = 100MB

# %r выводит адрес и порт клиента — без него Fail2ban бессилен.
log_line_prefix = '%m [%p] %q%u@%d %r '

log_connections = on
log_disconnections = on
log_statement = 'ddl'

Неудачная попытка входа с таким префиксом выглядит в логе так:

Строка лога, которую будет ловить Fail2ban
2026-07-22 10:14:03.812 UTC [24417] postgres@app 203.0.113.5(54321) FATAL:  password authentication failed for user "postgres"

Дальше дело за Fail2ban: три неудачи за десять минут — час бана на уровне фаервола.

/etc/fail2ban/jail.local
[postgresql]
enabled   = true
filter    = postgresql
logpath   = /var/log/postgresql/postgresql-*.log
port      = 5432
protocol  = tcp
banaction = iptables-multiport
# На Debian 12+ / RHEL 9, где бэкенд фаервола по умолчанию nftables:
# banaction = nftables-multiport
maxretry  = 3
findtime  = 600
bantime   = 3600
/etc/fail2ban/filter.d/postgresql.conf
[Definition]
failregex = ^.*\s<HOST>\(\d+\) FATAL:\s+password authentication failed for user .*$
            ^.*\s<HOST>\(\d+\) FATAL:\s+no pg_hba\.conf entry for host .*$
            ^.*\s<HOST>\(\d+\) FATAL:\s+role ".*" does not exist.*$
ignoreregex =

Фильтр ловит не только неверные пароли: попытка входа под несуществующей ролью и подключение, не подошедшее ни под одно правило pg_hba.conf, — такие же верные признаки перебора.

Чек-лист

ЧтоПризнак, что всё в порядке
pg_hba.confТолько hostssl + scram-sha-256, замыкающий reject; ни trust, ни md5
listen_addressesВнутренний адрес, наружу база не слушает
TLSTLSv1.2+, валидные сертификаты, на клиентах verify-full
Логи%r в префиксе, сессии и DDL пишутся
Fail2banДжейл включён, тестовая неудачная попытка входа попадает в бан
ФаерволПорт 5432 открыт только приложению — на уровне ОС или облачной группы безопасности
ПривилегииПриложение ходит не под postgres, а под ролью с минимумом прав
ОбновленияМинорные релизы ветки 16.x ставятся регулярно

Про привилегии одна деталь, которую часто тянут из старых инструкций: отзывать право записи у схемы public вручную нужно было до 15-й версии — начиная с неё обычные роли и так не могут создавать в ней объекты. Проверить стоит только базу, пережившую апгрейд со старых версий.

Причём здесь проверка сайта

Ни при чём — и это лучшая новость статьи. Наша проверка смотрит на сайт снаружи, глазами постороннего, и базу данных она не видит: не сканирует порты, не пробует подключиться, не перебирает пароли. Если всё настроено по этой статье, снаружи от вашего PostgreSQL не видно вообще ничего — к этому состоянию и надо стремиться.

Верно и обратное: база, которую видно из интернета, — находка, которую не покажет никакой пассивный отчёт, потому что искать её снаружи — уже не наблюдение, а вторжение. Слой «что видно с улицы» — заголовки, TLS сайта, забытые поддомены со стендами, открытые админки — проверяется за минуту; слой «что за фасадом» — только вот так, руками и конфигами. Нужны оба.

Проверьте всё это одной командой

Введите адрес — получите разбор с оценкой риска и объяснением каждой находки. Бесплатно и без регистрации.

Проверить сайт

Частые вопросы

База и приложение на одном сервере — нужен ли TLS?

Для подключений через Unix-сокет (строки local в pg_hba.conf) TLS не нужен: трафик не покидает машину. Шифрование обязательно для любых сетевых подключений — hostssl в pg_hba.conf их и обеспечивает, отклоняя нешифрованные попытки ещё до проверки пароля. Сокет при этом защищайте методом peer или scram, но не trust.

Чем плох md5, если пароль всё равно передаётся не открытым текстом?

Тем, что для входа достаточно украсть сам md5-хэш — например, из бэкапа или дампа таблицы pg_authid — и предъявить его вместо пароля. SCRAM устроен иначе: это протокол вызов-ответ, в котором хэш из базы сам по себе входа не даёт. С 14-й версии scram-sha-256 — метод по умолчанию, md5 остаётся только в конфигах, переживших несколько апгрейдов.

Что даёт require_auth из PostgreSQL 16?

Защиту от понижения метода аутентификации. Без него сервер-самозванец, к которому клиент пришёл без verify-full, может предложить метод password и получить пароль открытым текстом. require_auth=scram-sha-256 запрещает клиенту соглашаться на что-либо, кроме SCRAM, — вторая половина защиты от подмены сервера, дополняющая sslmode=verify-full.

Поможет ли перенос PostgreSQL с порта 5432 на нестандартный?

Как защита — нет: сканеры определяют СУБД по ответу, а не по номеру порта. Как гигиена — немного: срезается автоматический шум, целящийся строго в 5432. Это тот же случай, что перенос админки сайта с /admin: фонового перебора станет меньше, но полагаться на секретность адреса нельзя — закрывать нужно доступ, а не номер порта.