Базы данных редко взламывают. В них, как правило, просто входят. Подавляющее большинство инцидентов сводится к четырём вещам: порт отвечал всему интернету, пароль было легко угадать или он повторялся, одна всемогущая учётная запись выполняла все задачи в системе, а когда что-то пошло не так, восстановимой резервной копии не оказалось. Всё хитроумное из литературы по безопасности стоит позади этих четырёх пунктов, и если исправить только их, вы окажетесь впереди большинства боевых систем.
Это чек-лист, который можно пройти за один вечер, в порядке, при котором сначала убирается самый большой риск. В примерах используются PostgreSQL и MongoDB, потому что именно эти два движка люди чаще всего запускают сами, но схема одинакова для любой базы: решите, кто может до неё добраться, кто может войти, что разрешено каждой учётной записи, зашифруйте канал, ограничьте ущерб, который может нанести один клиент, и держите копию, которую вы доказанно умеете восстанавливать.
Одна мысль, которую стоит держать в голове. Безопасность - это не свойство, которое добавляют в конце, а набор решений о радиусе поражения. Все ошибки вы не предотвратите. На каждом шаге вопрос один: когда что-то пойдёт не так, как далеко это зайдёт.
Доступность: первая и самая большая победа#
Любая база данных, слушающая публичный адрес, будет найдена. Не «может быть», а именно будет. Массовые сканеры непрерывно прочёсывают всё адресное пространство в поисках 5432, 27017, 3306 и 6379, и на новом хосте с открытым портом первая попытка входа обычно случается через несколько минут после запуска. Волна вымогательства через MongoDB в 2017 году не была хитрым эксплойтом: это были тысячи экземпляров, привязанных к 0.0.0.0 с выключенной аутентификацией.
Начните с того, чтобы выяснить, что на самом деле слушает соединения:
$ ss -ltnpState Recv-Q Send-Q Local Address:Port ProcessLISTEN 0 244 127.0.0.1:5432 users:(("postgres",pid=812,fd=6))LISTEN 0 4096 0.0.0.0:27017 users:(("mongod",pid=944,fd=11))127.0.0.1:5432 доступен только с самой машины. 0.0.0.0:27017 доступен отовсюду, куда пускает firewall. Затем проверьте снаружи, потому что то, что думает машина, и то, что делает сеть, - разные вопросы:
$ nc -vz db.example.net 5432Connection to db.example.net 5432 port [tcp/postgresql] succeeded!Настройки, которые за это отвечают:
| Движок | Настройка | Безопасное значение |
|---|---|---|
| PostgreSQL | listen_addresses в postgresql.conf | localhost или один приватный адрес |
| PostgreSQL | правила host в pg_hba.conf | Конкретные адреса, никогда не 0.0.0.0/0 |
| MongoDB | net.bindIp в mongod.conf | 127.0.0.1 плюс конкретные адреса |
| Любой | Firewall | Входящие запрещены по умолчанию, разрешены поимённо указанные источники |
Если приложение и база находятся на одной машине, привяжите базу к localhost и можете не читать этот раздел дальше. Если нет, разрешите ровно те адреса, которым это нужно, и никакие другие. В статье правила firewall, которые действительно важны разобрано, как писать такие правила и не запереть себя самого, а статья удалённые подключения к PostgreSQL описывает три настройки, которые должны согласоваться, прежде чем удалённый клиент вообще сможет подключиться.
Аутентификация, которую нельзя угадать#
Когда порт доступен тем, кому нужно, следующий вопрос - кто вправе войти.
PostgreSQL решает это через pg_hba.conf, который читается сверху вниз, и срабатывает первая подходящая строка. На этом порядке многие попадаются: разрешающая строка выше запрещающей означает, что запрещающая никогда не сработает.
# TYPE DATABASE USER ADDRESS METHODlocal all postgres peerhostssl app app_rw 10.0.0.0/24 scram-sha-256hostssl app app_ro 10.0.0.0/24 scram-sha-256host all all 0.0.0.0/0 rejectИспользуйте scram-sha-256, а не md5, и никогда не используйте trust, что означает отсутствие пароля вообще. Задайте password_encryption = scram-sha-256 до создания пользователей, иначе их пароли будут храниться так, как предписывала прежняя настройка. hostssl вместо host отклоняет соединение, если оно не зашифровано, и это надёжнее, чем вежливо просить клиента.
MongoDB поставляется с выключенным контролем доступа, и это самая важная строка в mongod.conf:
security: authorization: enablednet: bindIp: 127.0.0.1,10.0.0.5 port: 27017Без неё любой, кто дотянулся до порта, - администратор. С ней MongoDB использует SCRAM-SHA-256, и каждой операции нужен пользователь.
Что до самих паролей: сгенерированные, длинные, уникальные для каждой базы, лежащие в менеджере паролей и никогда не повторяющиеся между staging и production. Двадцать случайных символов из генератора лучше запоминающейся фразы с подстановками, потому что в словаре атакующего подстановки уже есть. Если пароль хоть раз вставляли в чат, тикет или скриншот, он скомпрометирован - смените его.
Один пользователь на одну задачу#
Именно этот шаг превращает взлом в инцидент, а не в катастрофу, и именно его чаще всего пропускают, потому что один суперпользователь в строке подключения всегда работает.
Приложению не нужно создавать таблицы во время работы. Панели аналитики не нужно удалять строки. Инструменту миграций права на схему нужны на девяносто секунд в месяц, а не постоянно. Четыре роли покрывают большинство систем:
| Роль | Права | Кто использует |
|---|---|---|
| Владелец или суперпользователь | Всё | Ничего рутинного. Хранится на крайний случай |
| Пользователь миграций | Изменения схемы в одной базе | Только конвейер деплоя |
| Пользователь приложения | Чтение и запись в таблицах, без DDL | Работающее приложение |
| Пользователь только для чтения | Только SELECT | Панели, отчёты, люди, которые просто заглядывают |
В PostgreSQL это выглядит так:
CREATE ROLE app_rw LOGIN PASSWORD 'generated-here';CREATE ROLE app_ro LOGIN PASSWORD 'a-different-one';GRANT CONNECT ON DATABASE app TO app_rw, app_ro;GRANT USAGE ON SCHEMA public TO app_rw, app_ro;GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_rw;GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_rw;GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_rw;ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO app_ro;Строки с ALTER DEFAULT PRIVILEGES - те, о которых забывают. Без них каждая таблица, созданная следующей миграцией, будет невидима для пользователя приложения, и в три часа ночи вы получите ошибку доступа в production. В статье роли и права в PostgreSQL разобрана вся модель, в том числе то, почему в старых версиях у PUBLIC больше прав, чем вы ожидаете.
В MongoDB та же идея реализуется встроенными ролями, привязанными к одной базе:
db.createUser({ user: "app_rw", pwd: passwordPrompt(), roles: [ { role: "readWrite", db: "app" } ]})Никогда не давайте приложению root, dbOwner или что-либо с окончанием AnyDatabase. После этого в строке подключения нужен authSource, указывающий на базу, в которой был создан пользователь, - это самая частая причина, по которой верный пароль отклоняется; полное устройство описано в статье строки подключения MongoDB.
Тот же принцип относится и к людям. Учётные записи панели, ключи деплоя и дашборды должны иметь самую узкую роль, с которой можно работать, и назначенного владельца. Эта сторона разобрана в статье субпользователи и минимальные привилегии.
Шифруйте соединение и проверяйте сертификат#
Если база и приложение не на одной машине, учётные данные и каждая строка проходят по сети. Зашифровать это просто. Зашифровать так, чтобы вы действительно убедились, с кем соединились, - это ещё один шаг, и большинство строк подключения останавливаются перед ним.
У sslmode в PostgreSQL шесть значений, и пользы от них две:
sslmode | Шифрует | Проверяет сервер | Вердикт |
|---|---|---|---|
disable | Нет | Нет | Открытый текст |
allow, prefer | Возможно | Нет | prefer - значение по умолчанию, и оно молча откатывается назад |
require | Да | Нет | Уязвимо для машины посередине |
verify-ca | Да | Только цепочку сертификатов | Приемлемо |
verify-full | Да | Цепочку и имя хоста | Используйте это |
postgresql://app_rw:pw@db.example.net:5432/app?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca.pemВ MongoDB тот же довод, только с другим написанием: tls=true в URI, tlsCAFile, если сертификат выдан не публичным центром, и никогда tlsAllowInvalidCertificates=true, которое отключает проверку, а слово TLS в строке остаётся и создаёт ложное спокойствие.
Держите учётные данные вне кода#
Пароль от базы в репозитории git - это пароль в интернете, независимо от того, публичный репозиторий или нет: он остаётся в истории, в форках, в резервных копиях ноутбуков всех, кто его клонировал, и в любых индексах вашего git-хоста. Учётные данные должны лежать в переменных окружения или в хранилище секретов и подставляться во время выполнения, а .env должен быть в .gitignore с первого коммита, а не с того дня, когда вы заметили проблему.
Три практических правила:
- Никогда не передавайте пароль в командной строке. Он попадает в историю shell и в вывод
psдля каждого пользователя на машине. Используйте~/.pgpassс правами0600,PGPASSWORDв окружении или запрос пароля. - Разные учётные данные для staging и production, всегда. Staging-машина по определению менее доверенная, и общий пароль превращает её в лазейку.
- Когда кто-то уходит из команды, меняйте пароли. Отозванный доступ - это не то же самое, что забытый доступ.
В статье переменные окружения и секреты разобрана механика на хосте с панелью, где значения хранятся во вкладке Startup, а не в образе или репозитории.
Запросы, в которые нельзя внедрить код#
Инъекция и спустя десятилетия остаётся тем способом, которым чужие базы читают те, кому это не предназначалось. Механизм всегда один: пользовательский ввод склеивается с запросом, и поэтому ввод способен изменить структуру запроса, а не только его значения.
// Broken. An email of ' OR 1=1 -- returns every user.const sql = "SELECT * FROM users WHERE email = '" + email + "'";// Correct. The driver sends the query and the value separately.await client.query("SELECT * FROM users WHERE email = $1", [email]);# psycopg, the same idea. The second argument is never string formatting.cur.execute("SELECT * FROM users WHERE email = %s", (email,))Параметризованные запросы - это не приём экранирования, а возможность протокола: сначала разбирается выражение, затем подставляются значения, поэтому значение не может стать синтаксисом. Используйте их везде, в том числе для запроса, в безопасности которого вы уверены.
Две вещи, которые параметры не умеют, и именно там инъекция возвращается:
- Идентификаторы. Имена таблиц и столбцов не могут быть параметрами. Если столбец для сортировки приходит из строки запроса, сравните его со списком разрешённых имён и отклоняйте всё остальное. Никогда не подставляйте его в строку.
- Целые конструкции. Сборка фрагментов
WHEREконкатенацией строк - это тот же баг в более аккуратной обёртке, с ORM или без. Большинство ORM безопасны по умолчанию и перестают быть таковыми, как только вы используете их лазейку для сырого SQL.
В MongoDB есть своя версия. Если тело JSON-запроса передаётся прямо в документ запроса, клиент может отправить {"password": {"$ne": null}} и получить совпадение со всеми записями. Приводите входящие значения к ожидаемому типу до того, как они попадут в запрос, и отключите выполнение JavaScript на стороне сервера, а не полагайтесь на то, что $where будет использован аккуратно.
Этот раздел стоит после минимальных привилегий, потому что они работают вместе. Инъекция в учётную запись, которая может только читать три таблицы через SELECT, - это плохой день. Та же инъекция в суперпользователя может писать файлы и запускать команды.
Лимиты, чтобы один клиент не мог положить базу#
Доступность - часть безопасности, а проще всего вывести маленькую базу из строя, открывая к ней соединения, пока они не закончатся.
- `max_connections` в PostgreSQL - жёсткий потолок, и каждое соединение расходует память, работает оно или простаивает. На экземпляре с 1-2 ГБ значение 100 уже оптимистично. Поставьте перед базой пулер и задайте каждому приложению собственный
CONNECTION LIMITкомандойALTER ROLE app_rw CONNECTION LIMIT 20;. Арифметика объяснена в статье пулы соединений и лимиты. - `statement_timeout` не даёт одному зависшему запросу занимать ресурсы вечно. Задавайте его на роль, а не глобально, чтобы у пользователя отчётов поводок был длиннее, чем у веб-приложения:
ALTER ROLE app_rw SET statement_timeout = '15s'; - `idle_in_transaction_session_timeout` убивает сессии, которые открыли транзакцию и ушли. Они блокируют vacuum и держат блокировки, а веб-приложение с ошибкой порождает их сотнями.
- Ограничения частоты в самом приложении перед всем, что выполняет тяжёлый запрос для анонимного посетителя. Обычная жертва - поисковые эндпоинты. Этот приём описан в статье лимиты запросов и злоупотребления.
Резервные копии, которые вы действительно восстанавливали#
Резервная копия, которую никто не восстанавливал, - это гипотеза. Это последний пункт чек-листа, и именно он решает, будет ли инцидент неприятностью или концом бизнеса.
- Автоматически, по расписанию. Ручной backup - это backup, который вы перестанете делать в загруженную неделю, когда он нужнее всего.
- Вне той машины, которую он защищает. Копия на том же диске переживёт удалённую таблицу и ничего больше.
- Больше одного поколения. Повреждения и злонамеренные изменения нередко замечают через несколько дней, и единственная ночная копия к тому времени уже перезапишет хорошую.
- Восстановленная намеренно, в выбранное вами время. Раз в месяц-два восстанавливайте копию в пробную базу, посчитайте несколько строк, запустите на ней приложение. Это единственное доказательство того, что backup работает.
- Считающаяся чувствительными данными. Файл дампа - это вся база в одном скачиваемом объекте. Не оставляйте его в каталоге, доступном через веб, - на удивление частый способ, которым сайты сливают всё.
Механика для каждого движка описана в статье резервные копии и восстановление баз данных, а проверка восстановления до того, как оно понадобится - это та половина, которую пропускают. Если худшее уже случилось, в статье что делать, если сервер взломали описан порядок действий, и он не тот, который подсказывает инстинкт.
Логи, которые показывают, что изменилось#
Нельзя расследовать то, что не записано, а стандартное логирование в большинстве баз данных не фиксирует почти ничего полезного.
log_connections = onlog_disconnections = onlog_statement = 'ddl'log_min_duration_statement = 1000log_line_prefix = '%m [%p] %q%u@%d from %h 'log_statement = 'ddl' записывает каждое изменение схемы без шума от логирования всех запросов. log_min_duration_statement = 1000 записывает всё, что медленнее секунды, и заодно служит данными о производительности. %h в префиксе ставит адрес клиента в каждую строку, а именно он нужен в первую очередь, когда что-то выглядит неправильно.
Помимо самого движка, следите за тем, что меняется редко: новая роль, изменённый pg_hba.conf, пользователь, которому выдали больше прав, чем было. Это мелкие события с большим смыслом. Хранение логов разобрано в статье логи, которые стоит хранить, а журнал активности сервера в панели фиксирует, кто что сделал на стороне учётной записи.
Чек-лист#
Пройдите по нему один раз для каждой базы, которую вы запускаете. Всё, на что вы не можете ответить, - следующее, что нужно исправить.
| # | Проверка | Готово, когда |
|---|---|---|
| 1 | Порт не открыт всему миру | ss -ltnp и внешняя проверка порта совпадают |
| 2 | Аутентификация включена и использует SCRAM | Нет trust, нет md5, нет MongoDB без authorization |
| 3 | Пароли сгенерированы и уникальны | Ничего не повторяется между окружениями |
| 4 | Пользователь приложения не может менять схему | Миграции запускаются от другой роли |
| 5 | Есть роль только для чтения для людей и дашбордов | Никто не делает запросы от имени владельца |
| 6 | Соединения зашифрованы и проверяются | verify-full или tls=true с файлом CA |
| 7 | Учётные данные хранятся вне репозитория | .env игнорируется, значения подставляются во время выполнения |
| 8 | Каждый запрос параметризован | Никакой конкатенации строк, идентификаторы из белого списка |
| 9 | Лимиты соединений и запросов заданы | Один плохой клиент не может исчерпать сервер |
| 10 | Backup автоматический, вне машины и проверенный | В этом квартале вы восстановили хотя бы один |
| 11 | Соединения, DDL и медленные запросы логируются | Вы смогли бы восстановить картину вчерашнего дня |
| 12 | Версии движка и драйвера актуальны | Обновления безопасности ставятся в течение недель |
FAQ#
Достаточно ли очень длинного пароля, если порт публичный?
Он останавливает подбор и больше ничего. Публичный порт по-прежнему подвергает вас ошибкам на уровне протокола в той версии, которую вы запускаете, утечкам учётных данных из других мест и отказу в обслуживании через исчерпание соединений. Всегда ограничивайте и адреса источников. Аутентификация и доступность - разные меры защиты, и нужны обе.
Нужен ли TLS, если база в приватной сети?
Всё равно используйте. Приватные сети делят с другими арендаторами чаще, чем можно подумать по названию, взломанная внутренняя машина может подслушивать трафик, а включение TLS стоит одной строки в строке подключения. Единственное, где нужна осторожность, - проверка: require без verify-full шифрует, но не доказывает, кто ответил.
Защищает ли ORM от SQL-инъекций?
Для запросов, которые он строит сам, да. Риск - в методе сырых запросов, который есть в каждом ORM, и в местах, где идентификатор или конструкция собираются как строка, потому что построитель запросов не смог это выразить. Именно эти строки нужно проверять, и обычно это те, рядом с которыми стоит комментарий с извинениями.
Как часто нужно менять пароли к базе данных?
По расписанию смена паролей - в основном театр. По событию она необходима: кто-то уходит, учётные данные появились в логе или на скриншоте, взломана машина, где они хранились, или у стороннего инструмента, которому вы выдали доступ, случился инцидент. Устройте смену так, чтобы она занимала пять минут и не требовала деплоя, и вы сделаете её тогда, когда это важно.
Что самое ценное в этом списке?
Ограничение доступности, а затем проверенный backup. Первое предотвращает большую часть происходящего, второе означает, что остальное можно пережить. Всё прочее сужает ущерб между ними.
Мой хостер управляет базой. Что из этого по-прежнему моя забота?
Доступность, обновление движка и сама машина - забота хостера. Каких пользователей вы создаёте, какие права им даёте, проверяет ли ваша строка подключения TLS, параметризованы ли запросы, где хранятся учётные данные и восстанавливали ли вы когда-нибудь backup - всё это ваше, и именно отсюда берутся инциденты.




Комментарии
Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.