Задача звучала безобидно: внешний верстальщик сдаёт статичную вёрстку в GitLab, и она сама должна появляться на тестовом поддомене. Пуш в ветку — и через минуту можно смотреть. Самое простое решение — подключить его проект к тому же раннеру, которым выкладываются сайты. И это ровно то решение, после которого человек с правом пуша владеет всем сервером.

Это третья часть цикла «Общий сервер: безопасность и восстановление».

Почему простое решение опасно

Раннер-доставщик на общем сервере обычно устроен одинаково: контейнер с GitLab Runner, в который смонтирована папка со всеми сайтами на запись, джобы выполняются от root внутри контейнера, раннер групповой и берёт задания любого проекта группы. Для своих проектов это терпимо: команды в CI пишем мы сами.

Но команды выполняет не раннер, а файл .gitlab-ci.yml из репозитория. Кто может изменить этот файл в любом проекте, где доступен такой раннер, тот выполняет произвольные команды с правами на все сайты: прочитать wp-config.php соседа, подменить его код, удалить всё. Право Developer в GitLab этого достаточно — ветку можно защитить, но CI запускается и на других ветках.

Раннер с общим доступом к файлам сервера = ключ от сервера у каждого, кто может пушить в проект с этим раннером. Проверьте, кому в группе доступны ваши групповые раннеры.

Как сделали

Три независимых барьера, каждый закрывает свою дыру.

1. Отдельный раннер с минимумом прав

  • проектный, а не групповой: подключается вручную к проектам внешних исполнителей;
  • отдельный тег, «запускать задания без тегов» выключено — чужие джобы он не берёт;
  • внутри контейнера работает от пользователя веб-сервера, не от root;
  • видит только папки, перечисленные в его compose, — по одной на внешний проект;
  • без сокета Docker, без привилегий, с лимитом памяти 256 МБ.
services:
  runner:
    build: .
    user: "3000:3000"            # тот же uid, что у php-fpm и nginx сайта
    read_only: true
    mem_limit: 256m
    volumes:
      - ./config:/etc/gitlab-runner
      - /server/sites/dev.example.com/src:/deploy/dev.example.com   # и больше ничего
    restart: unless-stopped

Новый раннер под каждый проект не нужен: одного хватает на всех внешних, им добавляется строка в volumes.

2. CI-файл не в репозитории исполнителя

Даже ограниченный раннер выполнит любую команду из CI-файла в пределах своей папки. Поэтому сам файл лежит в нашем отдельном репозитории, а в настройках проекта исполнителя указан путь к нему (Settings → CI/CD → General pipelines → CI/CD configuration file):

dev-example.yml@our-group/ci-configs

Исполнителю в репозитории с конфигами — роль Reporter: читать файл GitLab ему нужно, менять — нет. Свой .gitlab-ci.yml в его репозитории просто игнорируется.

Путь назначения в команде выкладки пишите литералом, а не переменной. Переменные CI можно переопределить при пуше (push-опции, переменные ручного запуска), и rsync dist/ $TARGET превратится в «разложи куда скажу».

3. Сборка — не на боевом сервере

npm ci и npm run build исполняют код из package.json и всех зависимостей — то есть опять произвольный код. Сборка идёт на другом сервере в контейнере node:20, а на боевой раннер приезжает только готовая папка dist/ артефактом:

build:
  tags: [build]                 # раннер на сборочном сервере
  image: node:20
  script:
    - if [ -f package-lock.json ]; then npm ci; else npm install; fi
    - npm run build
  artifacts: { paths: [dist/], expire_in: 1 day }

deploy:
  tags: [designer-deploy]       # ограниченный раннер на боевом сервере
  needs: [build]
  script:
    - test -f dist/index.html || { echo "в сборке нет dist/index.html"; exit 1; }
    - rsync -a --delete dist/ /deploy/dev.example.com/

Закрыть тестовый поддомен от поисковиков — не файлами исполнителя

Вёрстка на открытом поддомене — готовый дубль будущего сайта в индексе. Закрываем на уровне nginx, чтобы ни один файл из сборки это не отменил: на каждом ответе X-Robots-Tag: noindex, nofollow, noarchive, /robots.txt всегда отвечает Disallow: /.

add_header в nginx наследуется только пока в блоке нет своих add_header. Добавили в location для статики заголовок кэширования — и X-Robots-Tag с уровня server на эти ответы больше не приходит. Проверка: curl -sI https://dev.example.com/style.css | grep -i x-robots.

Мелочи, на которых споткнулись

  • свежий аккаунт исполнителя без подтверждения личности в GitLab не запускает конвейеры вовсе — ошибка выглядит как «некорректный YAML» с подсказкой user not verified;
  • если в проекте включены общие облачные раннеры GitLab, сборка может уехать на них и упасть — их выключаем, групповые оставляем (нужен сборочный);
  • в бесплатном GitLab лимит участников на всю группу — пять человек; внешний исполнитель занимает место один раз, в скольких бы проектах ни был;
  • папке сайта нужен тот же владелец, что у раннера, иначе rsync упадёт с Permission denied.

Как закрыть доступ, когда работа закончена

  1. убрать исполнителя из участников проекта и репозитория с конфигами;
  2. убрать папку его сайта из volumes раннера и пересоздать контейнер;
  3. отключить раннер в настройках проекта.

Подключаете внешних исполнителей к своему CI?

Покажите, как устроены раннеры, — найду, где у исполнителя больше прав, чем нужно для его задачи.

· полезно?
АЧ
Алексей Чикун веб-разработчик, команда TRIOHEAD

Делаю сайты и сервисы целиком: от вёрстки до сервера. Пишу о том, что проверил на своих проектах.

Комментарии

// загружаю комментарии…

Комментарии публикуются после проверки. Ссылки и HTML не принимаются.