---
title: "What an untrusted PR can access on a self-hosted runner"
description: "Map the credentials, storage, network, and kernel boundaries of self-hosted CI, with a harmless Docker fixture for persistence and explicit access."
url: https://9apes.com/blog/self-hosted-runner-untrusted-pr-security/
---

# What an untrusted PR can access on a self-hosted runner

A pull request can change test scripts, dependency hooks and build commands.
When your workflow executes those changes, the submitted code inherits the
workload's access to credentials, storage and networks.

We tested two of those boundaries with a harmless Docker fixture. All five
checks passed: a new container could read a reused volume and a deliberately
supplied fake credential. Separate storage and an omitted variable produced the
expected absence checks. This was not a container escape or a real fork-PR test.

## Check the workflow's trust context

For a typical fork-triggered `pull_request` workflow, GitHub withholds ordinary
secrets and restricts `GITHUB_TOKEN` to read access. Check your actual repository
and organization settings, including fork approvals and private-repository
exceptions. [GitHub workflow events](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)

`pull_request_target` runs in the trusted base-repository context. Fetching
untrusted PR code and then building, installing dependencies or testing it can
expose that context's privileges. Checkout alone is not execution. A privileged
`workflow_run` that executes an untrusted artifact can create the same problem.
[GitHub pull_request_target guidance](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target)

Use `pull_request` for ordinary untrusted tests. Keep privileged follow-up jobs
small and treat incoming artifacts as untrusted data. Approval to run a PR does
not isolate the machine that executes it.

## Map what the job can access

A read-only repository token does not restrict a mounted disk or an internal
service reachable from the runner. Inspect these boundaries separately:

| Boundary | What to inspect |
|---|---|
| Credentials | Job permissions, environment variables, mounted secrets and cloud identities |
| Storage | Workspaces, named volumes, package caches, build caches and retained disks |
| Network | Routes, firewall rules, egress policy and reachable internal services |
| Host | Docker sockets, devices, host namespaces and privilege settings |
| Lifecycle | What survives completion, failure and cancellation |

Give deployment credentials only to the trusted jobs that need them. Keep token
permissions narrow, separate untrusted inputs from inline shell code, and pin
third-party actions to reviewed full commit SHAs.
[GitHub secure use](https://docs.github.com/en/actions/reference/security/secure-use)

Short-lived credentials reduce their useful lifetime. They do not prevent code
that receives them from using them while valid, even inside a VM.

## Five checks with synthetic data

The [fixture and observed results](/downloads/ci-lab/isolation-evidence.zip) include
the script, resolved BusyBox digest, source revision, environment records and
assertion log. On an owned disposable Linux Docker host, run from the extracted
bundle's root:

```bash
bash isolation/run.sh
```

From this repository, use `bash examples/ci-lab/isolation/run.sh`. The script
pulls `busybox:1.37.0`, records its digest and uses that digest for every invocation.
Set `ISOLATION_IMAGE` to the recorded digest to repeat with the same image.

It creates two named volumes, writes `synthetic-canary` to one, and checks later
containers with reused or separate storage. It also compares an explicitly
supplied fake credential with an omitted variable.

| Check | Observed result | What this establishes |
| --- | --- | --- |
| New container, reused named volume | Canary is readable | Replacing a container does not remove explicitly retained state |
| New container, separate empty volume | Canary is absent | That new volume does not expose the first volume's canary |
| Fake credential explicitly supplied | Value is readable | The workload receives the access intentionally given to it |
| Fake credential omitted | Variable is absent | The fixture does not implicitly forward that host variable |
| Cleanup | Both fixture volumes removed | The fixture removes the resources it created |

The recorded run used one GitHub-hosted `ubuntu-24.04` job and Docker client/server
28.0.4. Workload containers ran as UID 1000 with no network, a read-only root
filesystem, dropped capabilities, no privilege escalation and resource limits.
Only initial permission setup on the two empty volumes ran as UID 0. Pulling the
image used host networking; no real credentials, host directories or Docker socket
were exposed inside the workload containers.

These were sequential container invocations inside **one GitHub Actions job**.
They do not establish isolation between jobs, network-policy enforcement,
resistance to container escapes or any provider's production security posture.

## A fresh container can retain old state

Docker named volumes survive container removal. `--rm` removes the container,
but reattaching a named volume exposes its retained contents.
[Docker volume lifecycle](https://docs.docker.com/engine/storage/volumes/)

For each workspace, cache or disk, define whether it belongs to one run,
repository, organization or a broader group. Check whether a later run can execute
files restored from it. A fresh volume passing an absence check does not prove
secure physical erasure of the old volume.

## Where pods and microVMs help

Kubernetes Restricted Pod Security constrains privileges, user identity,
capabilities and seccomp. It is a hardening baseline, not a separate guest kernel
or an egress policy. Record any exceptions your builders require.
[Kubernetes Pod Security Standards](https://kubernetes.io/docs/concepts/security/pod-security-standards/)

Firecracker adds a KVM virtualization boundary and recommends its jailer for
process sandboxing. Network filtering remains the host integration's responsibility.
A microVM can separate guest kernels, but it does not revoke credentials supplied
to the guest, validate incoming artifacts or erase a reattached disk.
[Firecracker design](https://github.com/firecracker-microvm/firecracker/blob/main/docs/design.md)

Before sending untrusted code to a self-hosted runner, verify its credential
scope, storage reuse, network access and exposed host interfaces. Test cleanup
after failure and cancellation too. Keep that evidence with the runner
configuration so each access claim can be checked.
