Why are self-hosted GitHub Actions runners risky for public repositories, and what limits the exposure?
answer
- Anyone can open a pull request
- What survives the job is the real problem
- Consider what the next job inherits
- One-job-then-gone is the strongest control
- Scoping which repositories may use the fleet
basics
~20 sA pull request from any fork can propose workflow code that executes on your self-hosted runner, and a persistent runner keeps state between jobs — so one hostile run can steal or poison what the next job uses. GitHub's own guidance is not to use self-hosted runners on public repositories.
solid answer
~50 sOn a public repository anyone can open a pull request, and the `pull_request` event runs the fork's code on your runner. On a GitHub-hosted runner that is contained: fresh VM, no secrets exposed to fork pull requests, machine destroyed afterwards. On a **persistent self-hosted** runner the same code runs on a long-lived machine inside your network, with whatever the runner user can reach, and anything it leaves behind — a modified toolchain, a poisoned dependency cache, a credential file, a background process — is inherited by the next job on that machine. GitHub explicitly recommends against self-hosted runners on public repositories. The mitigations are: make runners **ephemeral** so no state survives a job, put them in **runner groups** scoped to specific private repositories, require approval for outside-contributor workflow runs, and isolate the network so a runner cannot reach production.
code
bash · 5 lines./config.sh --url https://github.com/acme/api \
--token AXXXXXXXXXXXXXXXXXXXXXXXXX \
--labels build-fleet \
--ephemeral \
--unattendedgo deeper
Know that a pull request from a stranger causes your workflow to run their code, and that a self-hosted machine keeps state between runs.
Explain the concrete attack: job one poisons the workspace, cache or PATH; job two, which may be trusted and privileged, inherits it.
Demonstrate the mitigation ladder in priority order and be able to justify why ephemeral runners plus network isolation buy more than any workflow-level tweak.
Own the policy: which repository classes may use the shared fleet at all, how runner groups and approval requirements are enforced organisation-wide, and what the fleet is permitted to reach.
## The threat A public repository accepts pull requests from strangers. A pull request can change files in `.github/workflows`, and the workflow that runs against that branch executes the contributor's code — build scripts, tests, whatever the diff contains. That is the intended behaviour of CI; the question is what the code lands on. On a **GitHub-hosted** runner the blast radius is bounded by design: the machine is created for that job, is not on your network, is destroyed afterwards, and fork pull requests receive a read-only `GITHUB_TOKEN` and no repository secrets. On a **persistent self-hosted** runner none of those bounds exist. The attacker's code runs as the runner's user, on a machine you own, typically inside a network where interesting things are reachable, and the machine survives the job. ## Why persistence is the multiplier The individual run is bad; persistence is what turns it into compromise: - **Poisoning the next job.** Job N writes a malicious binary onto `PATH`, edits a shell profile, plants a hook in the reused workspace, or corrupts a dependency cache directory. Job N+1 — which may be a trusted release build on the default branch, with real secrets — executes it. - **Background persistence.** A step can leave a daemon running after the job reports success, which then reads the environment of subsequent jobs. - **Credential harvesting.** Anything on the box the runner user can read — a cloud CLI profile, a Kubernetes kubeconfig, an SSH key, an instance metadata endpoint — is available to the hostile job even though *that* job was given no secrets. - **Lateral network access.** The runner was probably placed on-prem precisely because it can reach a database or an internal registry. So can the pull request. ## Mitigations, in the order that actually helps 1. **Don't.** GitHub's documented recommendation is to avoid self-hosted runners on public repositories. If the public repository needs no private network access, hosted runners remove the problem entirely. 2. **Ephemeral runners.** Configure with `--ephemeral` or use just-in-time runners so the agent accepts exactly one job then deregisters, and the underlying VM or container is discarded. This kills the poisoning-the-next-job class outright — the strongest single control after "don't". 3. **Runner groups.** At organization or enterprise level, place runners in a group restricted to selected repositories, and leave the group's allow-public-repositories setting off. This is what stops a new public repository from quietly gaining access to the private build fleet. 4. **Require approval for outside contributors.** Repository Actions settings can require a maintainer to approve workflow runs from first-time or all outside contributors, which puts a human between the pull request and execution. 5. **Network and identity isolation.** Give the runner its own segment, no ambient cloud credentials, and no route to production. Assume the job is hostile and ask what it can reach. ## The trigger nuance Candidates should know that `pull_request` from a fork runs untrusted code **without** secrets, while workflows designed to have secrets on a fork's content are the classic footgun — combining untrusted code with privileged context is the mistake, and a persistent self-hosted runner supplies the privileged context for free even when the workflow author was careful. ## What a strong answer sounds like "Untrusted code, long-lived machine, inside my network, with state that outlives the job. I'd move the public repository to hosted runners; if it must be self-hosted, I make the runners ephemeral, scope them by runner group, require approval for outside contributors, and treat the runner subnet as untrusted."
- Fork pull requests get no secrets, so what is actually at risk on a self-hosted runner?Everything that is on the machine or reachable from it: files the runner user can read, cached credentials from previous jobs, internal services on the network, and the state the next job will consume. The workflow secret model protects the workflow's secrets, not the host's environment.
- How does an ephemeral runner change the picture, and what does it cost?An ephemeral runner accepts one job and deregisters, so nothing a hostile job leaves behind is inherited — provided the underlying VM or container is also discarded, not just the agent. The cost is cold start on every job: no warm dependency caches, no pre-pulled images, and provisioning latency you must absorb with a pool or an autoscaler.
- What are runner groups for in this context?A runner group is the access-control boundary for a shared self-hosted fleet: at organization or enterprise level it restricts which repositories and which workflows may use those runners, and whether public repositories are allowed at all. Without groups, every repository in the organization can schedule onto the fleet.
A hosted runner is a hotel room burned down after each guest; a persistent self-hosted runner is a shared workshop where the last visitor could have swapped your tools.
saying these in an interview costs you the question
- Assuming no secrets means no risk
- Believing a self-hosted runner is wiped between jobs
- Trusting that only maintainers can trigger workflows
- Putting the build fleet on the production network
- Treating runner groups as a cosmetic organisation feature