</>CodeWithKarani

Push access is code execution: hardening a self-hosted runner

Karani GeoffreyKarani Geoffrey7 min read

Here is the sentence that belongs at the top of this article rather than buried in a caveat near the bottom:

If you install a self-hosted runner on your client's server, then anyone who can push to your repository can run commands on that server as the bench user.

Not "could potentially, under some circumstances". That is what a runner is for. It reads a workflow file from your repository and executes it. The workflow file is code, the repository is where your team pushes code, and the machine is in your client's office. This is a property of the design, not a bug in it, and GitHub says as much: a self-hosted runner can be persistently compromised by untrusted code in a workflow.

I am not raising this to talk you out of deploying to a client server you are not allowed to touch. I still use runners and I still recommend them. I am raising it because you want to be the person who explains this to the client, not the person it is explained to.

treat push access to the deployment repository as shell access to the client's server, because that is what it is, and then spend your effort narrowing what that shell can do rather than pretending it is not a shell.

  • Trigger only on protected tags. Never on push to a branch, never on pull_request.
  • Register the runner to one repository, run it as frappe, and give it no sudo beyond one wrapper script.
  • The deploy script lives on the server and is owned by the client, so changing the repository cannot change what a deploy does.
  • Tell the client how to stop it themselves, on day one, in writing.

The path, drawn honestly

From a pushed workflow change to the client's database A six step chain. Someone obtains push access to the repository, through a stolen token, an ex-contractor's account or a compromised laptop. They add a step to a workflow file. The event they need is produced. The runner on the client server polls, receives the job and executes it as the frappe user. That user can read site_config.json, which holds the database password in plain text, and from there the whole database. A marked boundary shows where the chain crosses out of your organisation and into the client's. 1. Someone gets push access A leaked token, an ex-contractor still on the team, a laptop left unlocked 2. They add one step to a workflow file It does not have to be a new file. An extra run: line is enough. 3. They produce the event that triggers it Which is why what you put in the on: block is a security decision Below this line the chain is inside the client's building, on the client's hardware 4. The runner polls, receives the job, executes it Faithfully, as designed, as the frappe user 5. cat sites/dev.client.local/site_config.json The database password, in plain text, as the framework intends 6. Every customer record in the site Steps 1 to 3 are yours Branch protection, tag rules and the trigger you chose all live here. This is where the mitigations are cheapest and most effective. Steps 4 to 6 are the client's Which user, which sudo rights, and whose script actually runs. And their ability to stop the service without asking you.
Nothing in this chain is exotic. Every step is the system doing exactly what it was built to do.

Step 5 is worth sitting with. It is not an exploit. site_config.json holds the database password in plain text because that is how Frappe finds its own database, as covered in the anatomy of a bench. Any process running as frappe can read it. So "runs as the bench user" and "can read the entire ERP database" are the same statement.

Seven layers, and what each one is actually worth

The mitigations as layers, with what each one stops and does not stop A three column table of seven mitigation layers. Tag-only triggers from a protected branch stop a pushed commit from deploying but not someone who can create a tag. The environment gate with the client as reviewer stops any unapproved run but not an approved one nobody read. Registering the runner to one repository stops a compromise elsewhere in the organisation but not a compromise of this repository. Running as the frappe user stops system-wide change but not access to the ERP data itself. A single narrow sudoers rule stops arbitrary root commands but not the restart it permits. A server-owned deploy script stops the repository changing what a deploy does but not an extra step running after it. Never using the self-hosted label on pull request jobs stops fork and untrusted pull request code but not a collaborator pushing directly. Layer What it stops What it does not stop Trigger on protected tags only Never on push or pull_request A commit pushed to any branch, including main, deploying anything Anyone who can create a v tag, so restrict that too Environment gate, client approves Required reviewer, self-review off Every unapproved run, including one triggered at 3am by a stolen token An approved run whose diff the reviewer did not actually read One repository, not the organisation Register at repo level, always A compromise of any other repository reaching this client's server A compromise of this repository, which is the one that matters Service runs as frappe, never root svc.sh install frappe Package installs, other users' data, persistence outside the bench Everything the frappe user owns, which is the entire ERP One sudoers rule, one wrapper No sudo to supervisorctl directly Arbitrary root commands smuggled through supervisorctl arguments The restart itself, which is fine, because that is all it can do Deploy script owned by the server Root-owned, not read from the repo A repository change silently altering what a deployment actually does A workflow adding its own step before or after calling the script Private repo, no PR jobs on the label Tests run on ubuntu-latest only Fork pull requests executing their own workflow changes on the client's box A collaborator who pushes directly, which is layers one and two's job
No layer is sufficient alone, and the right-hand column is the honest part. Read it before you tell a client this is safe.

The sudoers rule, in full

The deploy needs exactly one privileged action: restarting the bench processes. The obvious rule is wrong:

# DO NOT USE THIS
frappe ALL=(root) NOPASSWD: /usr/bin/supervisorctl

A command in sudoers with no arguments specified permits any arguments. So that line also permits sudo supervisorctl -c /tmp/mine.conf start anything, which is root code execution with extra steps. Writing the arguments inline instead is fragile, because sudoers argument matching is positional and the supervisor group names contain colons.

Use a wrapper the client owns. As root:

cat > /usr/local/sbin/frappe-restart <<'EOF'
#!/bin/sh
set -eu
exec /usr/bin/supervisorctl restart frappe-bench-web: frappe-bench-workers:
EOF
chown root:root /usr/local/sbin/frappe-restart
chmod 755 /usr/local/sbin/frappe-restart

Then exactly one rule, installed with visudo so a syntax error cannot lock everyone out:

visudo -f /etc/sudoers.d/frappe-restart
frappe ALL=(root) NOPASSWD: /usr/local/sbin/frappe-restart

Verify what you have actually granted, which is a habit worth keeping:

sudo -l -U frappe
User frappe may run the following commands on dev-client-local:
    (root) NOPASSWD: /usr/local/sbin/frappe-restart

One line of output. If that listing ever grows, someone has widened the client's exposure and it should be a conversation.

The wrapper is owned by root and not writable by frappe, which matters: if the runner could edit it, the rule would be self-defeating.

The deploy script belongs to the server

The strongest structural control on this list, and the one that takes the most discipline, is that the thing a deploy runs is not in your repository.

      - name: Deploy
        run: /home/frappe/deploy.sh ${{ github.ref_name }}

One line. The workflow's entire authority is "run the release procedure the server already defines, for this tag". It cannot change the backup step, cannot skip maintenance mode, cannot add a step that reads site_config.json and posts it somewhere, because none of that is in the repository.

Own the script as root and make it read-only to frappe:

chown root:frappe /home/frappe/deploy.sh
chmod 750 /home/frappe/deploy.sh

Now the runner can execute it and cannot rewrite it. Changing the deploy procedure becomes a deliberate act on the client's server rather than a line in a pull request, which is slower and is the point.

Be honest about the limit, which is in the diagram above: nothing stops a modified workflow adding a second step that does whatever it likes. What the ownership buys is that the deployment is trustworthy and any deviation is visible as an extra step in the run log, rather than hidden inside a script the reviewer assumed had not changed.

The trigger block is a security control

Two rules, no exceptions.

on:
  push:
    tags:
      - 'v[0-9]+.[0-9]+.[0-9]+'

No branches:, ever. A branch trigger means a push deploys, and a push is the cheapest thing an attacker can do.

And never put the self-hosted label on a job that a pull request can reach. The pull_request event runs in the context of the merge commit, which includes the pull request's own changes to workflow files. On a public repository that means a stranger's proposed workflow can execute on your client's server, gated only by an approval for first-time contributors. Keep the repository private, and keep test jobs on ubuntu-latest where they belong:

jobs:
  test:
    runs-on: ubuntu-latest          # never self-hosted
  deploy:
    needs: test
    runs-on: [self-hosted, dev-client-local]
    environment: production

The environment and required reviewer setup is what puts the client's hand on that second job. And if you are tempted to reach for pull_request_target to get secrets into a fork's pull request, read how the pwn request works first. It is the exact shape of attack this section is warning about.

Give the client the off switch, in writing

The last control is not technical and it is the one that changes the relationship. Tell them, on day one, exactly how to stop this without asking you:

cd /home/frappe/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh status

The runner stops polling immediately. No queued job, no future job, nothing you push can execute. Removing it entirely is sudo ./svc.sh uninstall followed by ./config.sh remove.

Put that in the handover document next to the install steps. It costs you nothing, it is true, and it converts "we let a vendor run an agent on our server" into "we run an agent we can stop". Security teams respond to controls they hold. So do clients.

What none of this fixes

Two things, and I would rather say them than have you find out in a review.

The runner is a persistent machine, not a clean one. GitHub is explicit that self-hosted runners have no guarantee of running in ephemeral clean virtual machines, so anything a compromised job writes to the filesystem is still there for the next job and the one after. If you want that property back, look at just-in-time ephemeral runners, at the cost of the setup no longer being something a client's IT person does once in ten minutes.

And the runner auto-updates itself, but the machine and its floors do not move on their own. actions/checkout@v5 needs runner 2.327.1 or newer, and with Node 20 removed from Actions in September 2026, a runner installed a year ago and forgotten will start failing on jobs that worked last month. That is availability rather than security, and it will still be your phone ringing.

If the client's security team reads all of this and still says no to running an agent, that is a legitimate position and there is a design for it: the pull model, where a systemd timer on their server polls for releases and nothing external can execute anything at all. And the complete assembled implementation, with the checklist you hand their IT team, is in the pillar walkthrough.

Frequently asked questions

Is a self-hosted runner more dangerous than an SSH deploy key?
It is differently dangerous. The SSH key is a bearer credential that works for anyone holding it, from anywhere, silently. The runner is an execution path that only fires when your repository produces a job, and that path can be gated by branch rules, tag rules and a human approval. One risk is a file that exists; the other is a workflow you can constrain.
Why not just give the frappe user full sudo so the deploy is simpler?
Because it converts push access to your repository into root on the client's server. The deploy needs exactly one privileged action, restarting the processes, and that fits in one sudoers line pointing at one wrapper script. Anything wider is a convenience you are buying with the client's risk budget.
Can a pull request from a fork really run on my runner?
On a public repository, yes. The pull_request event runs in the context of the merge commit, so a fork's changes to the workflow file are part of what executes. GitHub gates first-time contributors behind an approval, and that gate is a person clicking a button. Keep the repository private and never put the self-hosted label on a pull_request job.
What is the fastest way for the client to shut this down?
sudo ./svc.sh stop in the runner directory, or systemctl stop on the actions.runner unit. The runner stops polling immediately and no queued or future job can execute. They should be told this on day one, in writing, because a control they know how to use is worth more than one they have to ask you about.
#Security#GitHub Actions#CI/CD#Linux#Supervisor
Keep reading

Related articles