Приветствую всех! В своей прошлой и по совместительству первой статье я рассказывал про упаковку приложения в докер контейнеры (ссылка на нее находится в конце этой статьи). В комментариях мне сделали замечание, что я не упомянул про защиту приложения и запуск от non-root. Что ж, исправлюсь и сделаю это в отдельной статье. Напомню, что я написал простое приложение для голосование за лучший ресторан и попытался по простому объяснить, как произвести его контейнеризацию. Также уточню, что упор я делаю именно на упаковку приложения в докер контейнеры, а не на бизнес-логику и UI.
Есть несколько релизов:
- https://github.com/codyRhett/restaurantVote/tree/release/1.0.2 - версия проекта для запуска локально на компьютере. Для запуска необходимо установить postgresql и создать БД 
- https://github.com/codyRhett/restaurantVote/tree/release/1.0.1 - версия для запуска в контейнерах 
Но! конкретно в этой статье речь пойдет о версии:
- https://github.com/codyRhett/restaurantVote/tree/release/1.0.3 - версия для запуска в контейнерах от непривилегированного пользователя 
Запуск контейнера от непривилегированного пользователя (non-root) — это критически важный слой защиты от эксплойтов. Разберем, как это работает, на примерах и технических деталях. DeepSeek дает вот такое лаконичное определение эксплоиту:
Эксплоит — это программный код, скрипт или метод, который использует уязвимость в системе, приложении или сети для выполнения несанкционированных действий. Это может быть кража данных, получение контроля над устройством, нарушение работы системы и т.д.
Но мы же понимаем, что лучше один раз увидеть, чем семь раз услышать. Возьмем кейс, который в идеале никогда не должен произойти в вашем приложении. Для этого вернемся к предыдущей версии проекта, а именно клонируем релиз 1.0.1 (https://github.com/codyRhett/restaurantVote/tree/release/1.0.1)
Обратите внимание на REST контроллер UserRestController, а конкретнее на его тестовый endpoint:
 @GetMapping("/execute")
    public String executeCommand(@RequestParam("cmd") String cmd) {
        log.debug("executeCommand");
        try {
            Process process = Runtime.getRuntime().exec(cmd);
            BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
            return reader.lines().collect(Collectors.joining("\n"));
        } catch (IOException e) {
            return "Error: " + e.getMessage();
        }
    }В качестве RequestParam передается строка cmd, которая преобразуется в исполняемую команду. И злоумышленник может передать туда, например, команду rm -rf с указанием пути на системные файлы и спокойно удалить их, если контейнер запущен от привилегированного пользователя - root. Повторюсь:
... что ни один нормальный разработчик не сделает такого уязвимого эндпоинта.
Это я сделал чисто для примера. А способов выполнить данный скрипт много, в том числе просто зайти в контейнер через docker exec и выполнить эту команду там.
Как же это будет выглядеть?
Собираем war файл через mvn package. Далее исполняем docker-compose up и ждем пока создадутся контейнеры. После этого выполняем классический набор запросов в командной строке:

- docker ps - получаем список запущенных контейнеров 
- docker exec -it 49d672f4e359 /bin/bash - заходим в командную строку нашего основного запущенного контейнера 
- cd ../ - переходим в основную директорию 
- делаем GET запрос в эндпоинт: 
curl http://localhost:8080/api/user/execute?cmd=rm%20-rf%20/tmp/%20--no-preserve-rootКстати, этот же запрос можно кинуть через браузер. Этот GET запрос передает команду rm -rf /tmp --no-preserve-root, которая удаляет папку tmp вместе с ее содержимым.
- Далее исполняем команду ls и убеждаемся, что папки tmp больше нет. Тоже самое можно проделать с остальными папками. Думаю, не надо объяснять, почему это плохо. И вообще с большинством системных папок можем делать, что угодно - удалять, перезаписывать и т д. И все это потому, что контейнер запущен от root. 
Как обезопасить себя от такого поведения?
Клонируем себе релиз 1.0.3 (https://github.com/codyRhett/restaurantVote/tree/release/1.0.3). Отличие от предыдущих релизов заключается в двух файлах - dockerfile и docker-compose.yml. Я их переписал для безопасного запуска контейнеров, чтобы усложнить задачу злоумышленников взломать наш сервис.
Начнем с того, что необходимо немного модифицировать dockerfile, чтобы наглядно продемонстрировать как работают привилегии. Приведенный ниже код демонстрирует конфигурацию для запуска от non-root:
FROM adoptopenjdk/openjdk11:ubi
# Создаем системного юзера и группу с явным UID/GID
RUN useradd -r -u 1001 appuser && \
    # создаем директорию app
    mkdir /app && \
    # Настройка прав доступа к директории /app \
    # 7 (владелец): Чтение + запись + выполнение (rwx).
    # 5 (группа): Чтение + выполнение (r-x).
    # 0 (остальные): Нет прав (---).
     chmod 750 /app && \
    # Назначение владельца директории /app
    chown appuser:appuser /app && \
    mkdir -p /app/data/logs && \
    chmod -R 750 /app/data/logs && \
    chown -R appuser:appuser /app/data/logs
# укзываем рабочую дирректорию
WORKDIR /app
# Укажите переменную окружения для пути к логам
ENV LOG_DIR=/app/data/logs
# Копируем файлы с правами
ARG WAR_FILE=target/restaurantVote-1.0-SNAPSHOT.war
# Копирование файлов с назначением владельца и группы
COPY --chown=appuser:appuser ${WAR_FILE} /app/application.war
COPY --chown=appuser:appuser src/main/webapp /app/webapp
# Явное переключение пользователя и рабочей директории
# Используем UID вместо имени. Запуск от имени нового созданного юзера
USER 1001
ENTRYPOINT ["java","-jar","/app/application.war"]Что же тут нового и необычного?
- Мы создаем группу и пользователя appuser и присваиваем ему идентификатор 1001 
- Даем права папкам app, где лежит исполняемый файл и app/data/logs, куда будем складывать логи (chmod - дает права доступа к файлам и директориям, chown - смена владельца папок, потому что по умолчанию владельцами папок является root) 
- Копируем файлы программы в новые папки с назначением владельца и группы 
- Явно указываем от какого пользователя осуществляем запуск контейнера USER 1001 
Не буду расписывать тут подробно про то, какие бывают права. Если вкратце, то наши права зашифрованы числом 750. Первая цифра - права на владельца, вторая - на группу, третья на всех остальных. В нашем случае - 1. все права, 2. чтение+выполнение и 3. нет прав соответственно. В докер файле я привел просто пример, как можно использовать права непривилегированного пользователя. Можно создавать папки, файлы и настраивать к ним такие права доступа, какие пожелаем.
Следующий этап - это уже настройка непосредственно контейнера
Создание DOCKER-COMPOSE
Приведенный ниже код демонстрирует docker-compose.yml для безопасного запуска контейнеров
version: '2'
services:
  app:
    image: 'restaurant'
    build: .
    security_opt:
      - no-new-privileges
    cap_drop:
      - ALL
    volumes:
      - ./host_logs:/app/data/logs  # папка на хосте -> контейнер
      - app_volume:/app/data/logs
    user: "1001:1001"
    container_name: app
    ports:
      - "8080:8080"
    depends_on:
      - db
    environment:
      - SPRING_DATASOURCE_URL=jdbc:postgresql://db:5433/restaurant
      - SPRING_LIQUIBASE_URL=jdbc:postgresql://db:5433/restaurant
      - SPRING_DATASOURCE_USERNAME=postgres
      - SPRING_DATASOURCE_PASSWORD=1234
      - SPRING_JPA_HIBERNATE_DDL_AUTO=update
  db:
    image: 'postgres:latest'
    container_name: db
    build: .
    user: "999:999"
    security_opt:
      - no-new-privileges
    cap_drop:
      - ALL
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=1234
      - POSTGRES_DB=restaurant
      - PGPORT=5433
volumes:
  app_volume:На что тут стоит обратить внимание?
security_opt:
  -  no-new-privilegesЭта опция запрещает процессам внутри контейнера повышать свои привилегии (например, через sudo или su). Даже если злоумышленник получит доступ к контейнеру, он не сможет стать root.
cap_drop:
  -  ALLЭта опция отключает все привилегии ядра Linux для контейнера.
- Изменить владельца файлов (CAP_CHOWN). 
- Работать с сетевыми настройками (CAP_NET_ADMIN). 
- Монтировать файловые системы (CAP_SYS_ADMIN). 
user: “1001:1001”Эта опция говорит о том, что контейнер запускается от пользователя с идентификатором 1001 (которого мы создали на предыдущем шаге)
Теперь проделываем те же действия, как и для релиза 1.0.1. НО! Обязательно выполнить билд без использования кэша через команду:
docker compose build --no-cacheЭто делаем для того, чтобы не наступать на те же грабли, что и я. В кэше сохранился мой предыдущий образ, и для создания контейнера использовался именно он, и я долго не мог понять, что происходит.
После этого запускаем контейнеры, заходим в restaurant и через команду ls -l можем видеть владельцев файлов и директорий в корне контейнера. Также через whoami можно посмотреть пользователя, от которого запущен контейнер.

Далее заходим в bash контейнера через docker exec и кидаем GET запрос на удаление папки tmp:
curl http://localhost:8080/api/user/execute?cmd=rm%20-rf%20/tmp/%20--no-preserve-root
Что же мы видим? Папка tmp не удалилась, потому что владельцем этой папки является root, а контейнер запущен от пользователя appuser, у которого мы сильно урезали права. Также попробуем напрямую удалить файлы из папки app.

Видим, что мы успешно удалили файл application.war, потому что владельцем папки является пользователь appuser, от которого и запущен контейнер.
Собственно, это все!
Итого: Если злоумышленник и зайдет в контейнер, то он сможет удалить только то, что не очень важно для нас (благодаря гибкой настройке прав). Например, как в данном случае, не сможет удалить системные файлы, владельцем которых является root. В зависимости от ваших потребностей вы можете ставить свои ограничения, но запуск контейнера ВСЕГДА должен осуществляться НЕ от Root!
Вы спросите, а какой смысл, если пользователь, например, зайдет в контейнер из под root и вообще возможно ли такое? А я отвечу - это проблемы инфраструктуры и такого в принципе не должно быть. А запуск не от привилегированного пользователя - просто необходимая ступень защиты, которая должна усложнить жизнь злоумышленникам. И эту защиту можно обойти, но на это нужно время.
Использование appuser подразумевает, что по умолчанию в контейнере нет пользователя root, а все процессы запущены с минимальными привилегиями.
Моя предыдущая статья, где подробно на примерах рассказываю, что такое контейнеризация и как упаковывать приложения в докер контейнеры:
Docker для начинающих: простое развертывание приложения за несколько шагов
 
           
 
goldexer
Система, я так понимаю, должна быть запущена на виртуалке? Ну так... для надёжности
Rhett_cody Автор
Ага. Либо в облаке