Skip to content

Run only reviewed main-branch workflows on the production runner #437

Description

@HMarzban

Summary

The production runner prod.docs.plus holds the production .env and the Docker socket. Any workflow that names it can run there. That includes a branch pushed by anyone with write access. The production environment reviewer does not stop this, because the job's YAML comes from that branch. Separately, Traefik reads its routing config from the runner's working copy, so any checkout on that runner changes live routing.

  • Severity: Medium
  • Area: GitHub Actions settings, .github/workflows/, docker-compose.prod.yml
  • Source: security review of 2026-10-06, finding M11

Where

  • Jobs on the production runner: .github/workflows/prod.docs.plus.yml:420 (deploy) and :968 (uptime-kuma-deploy), .github/workflows/observability.docs.plus.yml:37 and :147. Any pushed workflow can add another job with runs-on: prod.docs.plus.
  • The production deploy's workflow_dispatch arm has no branch check: prod.docs.plus.yml:429-433. Its environment: production (:434) is the only gate.
  • observability.docs.plus.yml:13 accepts workflow_dispatch from any branch. It has no environment: on purpose (:48-49), so nothing reviews it.
  • Traefik config is bind-mounted from the runner workspace: docker-compose.prod.yml:67-68. The file provider reloads on change: scripts/traefik/traefik.yml:85-87.
  • Runners are registered to the repo, not to an org runner group: .github/workflows/runner-watchdog.yml:33 lists repos/${GITHUB_REPOSITORY}/actions/runners.
  • Staging uses the same pattern: .github/workflows/stage.docs.plus.yml:194 (runs-on: stage.docs.plus).

Fix plan

  1. Runner groups (maintainer, org settings).
    • First confirm that the org plan offers "Selected workflows" on runner groups. If it does not, stop and report back before step 2.
    • Create an org runner group for the prod.docs.plus runner. Allow only this repository, with "Allow public repositories" on.
    • Set "Selected workflows" to docs-plus/docs.plus/.github/workflows/prod.docs.plus.yml@refs/heads/main and docs-plus/docs.plus/.github/workflows/observability.docs.plus.yml@refs/heads/main.
    • Move the stage.docs.plus runner to a second group, with stage.docs.plus.yml@refs/heads/dev.
    • Re-register the runners in their groups if a move is not offered. Then update runner-watchdog.yml:33 if the runners no longer list under the repo.
  2. production environment (maintainer). Set "Deployment branches" to main only.
  3. Workflow guards. These make a dispatch from another branch skip cleanly, not wait for a runner forever.
    • prod.docs.plus.yml:433: add github.ref == 'refs/heads/main' && inside the workflow_dispatch arm of the deploy job's if.
    • observability.docs.plus.yml:47 and :149: add github.ref == 'refs/heads/main' to both jobs' if. Inside workflow_call, github.ref is the caller's ref, so the push-to-main path still runs.
    • Do not add environment: production to the observability jobs. The comment at :48-49 explains why it stays off.
  4. Traefik config path. The observability job checks out code on the production runner with no reviewer. Today that checkout also rewrites live routing.
    • In docker-compose.prod.yml:67-68, change both mount sources to ${TRAEFIK_CONFIG_DIR:-./scripts/traefik}/.... Staging uses the same compose file, so it keeps the default.
    • In the deploy job of prod.docs.plus.yml, copy scripts/traefik/ to ${DEPLOY_STATE_DIR}/traefik/ just before up -d traefik redis (:646-647). Add TRAEFIK_CONFIG_DIR=/opt/projects/prod.docs.plus/.deploy/traefik to the production host .env, so every compose call with --env-file agrees.
    • The mount change recreates Traefik once. Deploy it in a quiet window.

Out of scope

  • Fork pull requests on the production runner (finding C2). Require approval before fork pull requests run workflows #440 owns that setting.
  • The staging Traefik mount. Staging keeps the workspace path until someone asks for the same change there.
  • Alloy and cAdvisor reach the Docker socket (review finding L17).

Acceptance criteria

  • A test job with runs-on: prod.docs.plus on a non-main branch never starts on the production runner. The job only echoes text.
  • Dispatching CI/CD Production or CI/CD Observability from a non-main branch skips every job on the production runner.
  • gh api repos/docs-plus/docs.plus/environments/production shows a deployment branch policy that allows only main.
  • Checking out another branch in the runner workspace does not change the files Traefik reads.
  • A normal push to main still deploys the app, observability and Uptime Kuma.

Verify

  1. Read the branch policy with gh api repos/docs-plus/docs.plus/environments/production.
  2. Push a throwaway branch with a one-step workflow: runs-on: prod.docs.plus, run: echo ok. Confirm it never gets a runner, then cancel it and delete the branch.
  3. On the production host, run docker inspect traefik --format '{{json .Mounts}}'. Both config sources must be under /opt/projects/prod.docs.plus/.deploy/traefik/.
  4. Watch the next main deploy run to the end.

Related


Generated by Claude Code

Activity

  1. added theissue type on Oct 6, 2026
  2. changed the title [-][Security] Branch-write access runs code on the production server without review[/-] [+]Run only reviewed main-branch workflows on the production runner[/+] on Oct 6, 2026
  3. added
    SecuritySecurity, access control, and data exposure
    and removed
    bugSomething isn't working
    on Oct 6, 2026
  4. HMarzban commented on Oct 9, 2026

    @HMarzban
    CollaboratorAuthor

    Code half is live (73e5bd972): production jobs skip on a dispatch from any branch but main, and Traefik reads its config from the deploy copy under .deploy/traefik.

    Left before closing (maintainer, GitHub settings): set the production environment's deployment branches to main only, and, if the org plan offers Selected workflows, a runner group that accepts only prod.docs.plus.yml and observability.docs.plus.yml from refs/heads/main. Also confirm the mounts with docker inspect traefik --format "{{json .Mounts}}".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    DevOpsSecuritySecurity, access control, and data exposure

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions