Справочник DBA: базы данных изнутри читается по порядку

Статья 11.7 · раздел 11 «Безопасность и соответствие» · 86 из 109

Безопасные настройки Redis и MongoDB: protected mode, ACL, bind и авторизация с проверкой

опубликовано
читать
≈ 8 мин
автор
среда и версия
Redis 8.10.3 в контейнере redis:8 и MongoDB 8.3.13 в контейнере mongo:8, у каждого --memory 1g, общая сеть dba-rsec-net, клиент — соседний контейнер; опыт выполнен 28 октября 2026, данные — несколько ключей и один документ
содержание решать задачи SQL на собеседовании

Redis и MongoDB ставят в сеть раньше, чем настраивают: контейнер поднялся, приложение подключилось, дальше «потом». Защита здесь состоит из слоёв, и каждый отвечает на свой вопрос: кто достучится до порта (bind, net.bindIp), пускает ли сервер без пароля (protected-mode, requirepass, security.authorization), что можно после входа (ACL, роли). Проверять нужно каждый слой отдельно: пароль не спасёт от FLUSHALL, а закрытый порт не заменит пароль. Аналог для PostgreSQL — pg_hba.conf и SCRAM и матрица ролей; устройство самих движков — в статьях про Redis и Valkey и MongoDB.

DBA / 01

Пять настроек защиты и ответ сервера, который каждую подтверждает

Пять слоёв защиты Redis и MongoDB и ответ сервера на проверку Пять строк. bind и bindIp: соединение с соседнего контейнера отвечает Connection refused. protected-mode: пока у default нет пароля, соседу отвечает DENIED. requirepass и authorization: без входа Redis отвечает NOAUTH, MongoDB требует аутентификацию. ACL и роль read: запись отвечает NOPERM в Redis и not authorized в MongoDB. Минус flushall в ACL: FLUSHALL отвечает NOPERM даже вошедшему. настройка что проверяет ответ сервера bind, bindIp кто вообще откроет TCP-соединение Connection refused ECONNREFUSED protected-mode у default нет пароля: пускает только loopback DENIED Redis is running in protected mode ... requirepass кто вошёл: пароль в Redis, authorization в MongoDB NOAUTH Authentication required. Mongo: requires authentication ACL, роль read что можно после входа: команды и ключи NOPERM ... the 'set' command Mongo: not authorized on shop -flushall команды, закрытые даже вошедшему NOPERM ... 'flushall' command
Слои независимы: сервер, закрытый по bind, всё равно нуждается в пароле и правах, а пароль без ACL оставляет вошедшему все команды.

Стенд

Контейнеры в одной сети, клиентом служит соседний контейнер (для MongoDB — свой клиентский контейнер dba-rsec-mcli на образе mongo:8, в нём есть mongosh): так проверяется то, что увидит приложение с другого хоста, а не localhost.

docker network create dba-rsec-net
docker run -d --name dba-rsec-redis --network dba-rsec-net --memory 1g redis:8
docker run -d --name dba-rsec-cli   --network dba-rsec-net --memory 1g redis:8 sleep 3600

Redis: protected mode и что делает образ

Официальный образ redis:8 запущен без конфигурации. Соседний контейнер проходит без пароля:

docker exec dba-rsec-redis redis-cli CONFIG GET protected-mode
docker exec dba-rsec-redis redis-cli CONFIG GET bind
docker exec dba-rsec-cli redis-cli -h dba-rsec-redis PING
docker exec dba-rsec-cli redis-cli -h dba-rsec-redis SET k v
вывод:
protected-mode
no
bind
* -::*
PONG
OK

Сервер слушает все интерфейсы, а protected-mode выключен, хотя по умолчанию в коде он включён: createBoolConfig("protected-mode", ..., 1, ...). Причина видна в истории слоёв образа (docker history --no-trunc redis:8): при сборке выполняется sed над src/config.c, который меняет эту единицу на ноль. Контейнер без пароля, опубликованный на внешний порт, поэтому открыт всему, до чего доходит порт. Включаем режим на живом сервере и повторяем с соседа:

docker exec dba-rsec-cli redis-cli -h dba-rsec-redis CONFIG SET protected-mode yes
docker exec dba-rsec-cli redis-cli -h dba-rsec-redis PING
docker exec dba-rsec-redis redis-cli PING
вывод:
OK
DENIED Redis is running in protected mode because protected mode is enabled and no password is set for the default user. In this mode connections are only accepted from the loopback interface. If you want to connect from external computers to Redis you may adopt one of the following solutions: 1) Just disable protected mode sending the command 'CONFIG SET protected-mode no' from the loopback interface by connecting to Redis from the same host the server is running, however MAKE SURE Redis is not publicly accessible from internet if you do so. Use CONFIG REWRITE to make this change permanent. 2) Alternatively you can just disable the protected mode by editing the Redis configuration file, and setting the protected mode option to 'no', and then restarting the server. 3) If you started the server manually just for testing, restart it with the '--protected-mode no' option. 4) Set up an authentication password for the default user. NOTE: You only need to do one of the above things in order for the server to start accepting connections from the outside.
PONG

Ответы идут по порядку команд: OK у CONFIG SET, DENIED у PING с соседа, PONG у локального PING. Условие проверяется при принятии соединения:

    if (server.protected_mode &&
        DefaultUser->flags & USER_FLAG_NOPASS)
    {
        if (connIsLocal(conn) != 1) {
            char *err =

Это clientAcceptHandler в src/networking.c, строки 1608-1611: режим срабатывает, пока у пользователя default нет пароля и клиент не локальный. Документация Redis говорит, что режим появился в версии 3.2.0. Установка пароля снимает условие, а не заменяет остальные слои: ниже он нужен вместе с ACL.

Redis: пароль и ACL

requirepass задаёт пароль пользователю default. Ответ без входа и со входом:

docker exec dba-rsec-redis redis-cli CONFIG SET requirepass s3cret
docker exec dba-rsec-cli redis-cli -h dba-rsec-redis PING
docker exec dba-rsec-cli redis-cli --no-auth-warning -h dba-rsec-redis -a s3cret PING
вывод:
OK
NOAUTH Authentication required.
PONG

Пользователей создают командой ACL SETUSER. Здесь app читает и пишет только ключи app:* и не может ничего из категории @dangerous, ro только читает, ops может всё, кроме FLUSHALL и FLUSHDB:

ACL SETUSER app on >apppass ~app:* +@read +@write +@connection -@dangerous +acl|whoami
ACL SETUSER ro  on >ropass  ~app:* +@read +@connection +acl|whoami
ACL SETUSER ops on >opspass ~* &* +@all -flushall -flushdb

Строка +acl|whoami добавлена не сразу: первая версия правил для app и ro с -@dangerous отвечала на ACL WHOAMI отказом NOPERM User app has no permissions to run the 'acl|whoami' command, потому что категория @dangerous включает команды ACL. Проверка под каждой ролью (AUTH и затем команды в одной сессии):

вывод (ro):
OK
ro
NOPERM User ro has no permissions to run the 'set' command

вывод (app):
OK
app
OK                                         <- SET app:1 hello
hello                                      <- GET app:1
NOPERM No permissions to access a key      <- SET other:1 x
NOPERM User app has no permissions to run the 'flushall' command

вывод (ops):
OK
ops
OK                                         <- SET other:1 x
NOPERM User ops has no permissions to run the 'flushall' command
NOPERM User ops has no permissions to run the 'flushdb' command

Отказ по ключу и по команде различаются текстом, а в ACL LOG каждый отказ записан с полями reason, object и username: для ops — reason command, object flushall, username ops. Читать его стоит как журнал неудачных попыток.

Решение «вошёл — значит, можно» принимает функция проверки прав. Первые же строки пропускают пользователя с полным набором прав, остальных проверяют по селекторам:

    /* Quick check if the user has all permissions, return early if so. */
    if (likely(listFirst(u->selectors) != NULL)) {
        aclSelector *s = listNodeValue(listFirst(u->selectors));
        const uint32_t all_perms = SELECTOR_FLAG_ALLCOMMANDS |
                                   SELECTOR_FLAG_ALLKEYS |
                                   SELECTOR_FLAG_ALLCHANNELS;
        if ((s->flags & all_perms) == all_perms) return ACL_OK;
    }

Это ACLCheckAllUserCommandPerm в src/acl.c, строки 1897-1904; ACLCheckAllPerm из карточки теперь только обёртка над ней. Вывод для эксплуатации: пользователь default из ACL LIST остался с ~* &* +@all и паролем в виде хеша. requirepass закрыл вход, но не права: каждый, кто знает пароль, получает все команды, поэтому приложению выдают собственного пользователя, как app.

rename-command

Старый способ закрыть CONFIG или FLUSHALL — rename-command CONFIG "". В redis.conf версии 8.10.3 раздел назван «Command renaming (DEPRECATED)», а рядом сказано: избегай этой опции и убирай команды у пользователя через ACL, оставляя их только административному пользователю (строки 1160-1166). В этой статье rename-command не запускался; решение — ACL с -@dangerous или -flushall, как выше.

Redis: bind

bind определяет, на каких адресах сервер вообще принимает TCP. Меняется на лету:

docker exec dba-rsec-redis redis-cli --no-auth-warning -a s3cret CONFIG SET bind "127.0.0.1 -::1"
docker exec dba-rsec-cli redis-cli --no-auth-warning -h dba-rsec-redis -a s3cret PING
docker exec dba-rsec-redis redis-cli --no-auth-warning -a s3cret PING
вывод:
OK
Could not connect to Redis at dba-rsec-redis:6379: Connection refused
PONG

Пароль здесь не спрашивали: соединение не установилось до аутентификации. Если приложению с другого хоста нужен доступ, в bind перечисляют конкретный внутренний адрес, а не * -::*.

MongoDB: авторизация, bindIp и роль read

Образ mongo:8 без параметров запускает mongod, слушающий все интерфейсы: getCmdLineOpts вернул {"net":{"bindIp":"*"}}, а в журнале старта есть предупреждение Access control is not enabled for the database. Read and write access to data and configuration is unrestricted. Соседний контейнер без учётных данных:

вывод (listDatabases, insertOne, connectionStatus с соседа):
authenticatedUsers: [], authenticatedUserRoles: []
[ 'admin', 'config', 'local' ]
true                                       <- insertOne в shop.orders принят

Документация MongoDB называет значением net.bindIp по умолчанию localhost: «MongoDB binaries, mongod and mongos, bind to localhost by default» (IP Binding); образ Docker переопределяет это: в getCmdLineOpts виден *. Пересоздаём контейнер с файлом настроек, где включены оба слоя, и bindIp пока только локальный:

security:
  authorization: enabled
net:
  bindIp: localhost
вывод (сосед):
MongoNetworkError: connect ECONNREFUSED 192.168.48.4:27017

Первого пользователя создают из контейнера сервера: пока пользователей нет, MongoDB разрешает только это и только с localhost. Затем в файле bindIp: localhost,dba-rsec-mongo (имя узла превращается в адрес контейнера при старте), перезапуск и проверка с соседа:

вывод (сосед, без учётных данных):
authenticatedUsers: [], authenticatedUserRoles: []
MongoServerError: Command find requires authentication
MongoServerError: Command listDatabases requires authentication

Порт теперь открыт для соседа, но данных без входа он не отдаёт. Пользователь report создан с ролью read на базе shop:

вывод (report): find
[ { sku: 'a1', qty: 2 } ]

вывод (report): insertOne
MongoServerError: not authorized on shop to execute command { insert: "orders", documents: [ { sku: "b2", qty: 1, _id: ObjectId('6ae2645fc514ac595b972e4b') } ], ordered: true, lsid: { id: UUID("5b22baaa-f6e7-46e7-bace-ecba1a5cc537") }, $db: "shop" }

вывод (report): db.runCommand({connectionStatus: 1})
authenticatedUsers: [ { user: 'report', db: 'shop' } ]
authenticatedUserRoles: [ { role: 'read', db: 'shop' } ]

С showPrivileges: true команда возвращает два ресурса — shop целиком и shop.system.js; в действиях первого есть find, listCollections, listIndexes, dbStats, и нет insert. Именно этот набор сверяется при каждой команде:

    if (_authenticatedUser == boost::none) {
        return false;
    }

    const auto& user = _authenticatedUser.value();
    // Safeguard that cross-tenant privileges are only granted when users have cluster-useTenant.
    if (MONGO_unlikely(!_nonTenantClusterActions.contains(ActionType::useTenant) &&
                       (user->getName().tenantId() != rp.tenantId()))) {
        return unmetRequirements.empty();
    }
    return std::any_of(search.cbegin(), search.cend(), [&](const auto& pattern) {
        unmetRequirements.removeAllActionsFromSet(user->getActionsForResource(pattern));
        return unmetRequirements.empty();
    });

Это конец _isAuthorizedForPrivilege в authorization_session_impl.cpp (строки 897-910, тег r8.3.13): без вошедшего пользователя проверка заканчивается false, а иначе из требуемых действий вычитаются действия пользователя на подходящих ресурсах, и разрешено только если ничего не осталось. У report действия insert нет, поэтому остаток не пуст.

Чем кончается «поставили и забыли»

Открытые без пароля Redis и MongoDB — хорошо задокументированная причина массовых инцидентов. В конце декабря 2016 — январе 2017 года исследователь Виктор Геверс сообщил о захвате MongoDB без защиты с заменой данных и требованием выкупа (сообщение BleepingComputer). В сентябре 2022 года Censys насчитал на публичных адресах 350 675 сервисов Redis, из которых 39 405 (11 %) не требовали аутентификации (отчёт Censys, 19.09.2022). Подробности атак здесь не разбираются.

Чек-лист

  1. На внешнем интерфейсе — только нужные адреса: bind в Redis, net.bindIp в MongoDB. Проверка — подключение с соседнего контейнера, ожидаемый ответ Connection refused или ECONNREFUSED.
  2. Не полагайся на образ: redis:8 собран с protected-mode no, mongo:8 слушает все интерфейсы. Проверь CONFIG GET protected-mode и getCmdLineOpts.
  3. Включи security.authorization: enabled и задай default в Redis пароль; проверка — NOAUTH и requires authentication без входа.
  4. Выдай приложению собственного пользователя: ACL с шаблоном ключей и -@dangerous в Redis, роль read или readWrite на одной базе в MongoDB; проверка — NOPERM и not authorized.
  5. Проверь права ровно теми командами, которые должны упасть, и смотри ACL LOG.

Убрать стенд: docker rm -f -v dba-rsec-redis dba-rsec-cli dba-rsec-mongo dba-rsec-mcli && docker network rm dba-rsec-net.

Источники

Выжимки статей в Telegram: одна ловушка, код с выводом и ссылка на полный разбор. t.me/koddo_ru