DEV Community

Cover image for GitHub Actions Checkout v7: Why Fork Pull Request Safety Required a 362KB Credential Isolation Rewrite
mech.app
mech.app

Posted on Originally published at mech.app

GitHub Actions Checkout v7: Why Fork Pull Request Safety Required a 362KB Credential Isolation Rewrite

The most-used GitHub Action just shipped breaking changes to how it handles fork pull requests. Checkout v7 refuses to check out fork code by default when workflows run with elevated privileges, and it moved credentials out of .git/config into ephemeral files under $RUNNER_TEMP. Both changes address the same problem: containerized actions and fork contributors can read state they should not see.

This is not a feature release. This is a security boundary rewrite for the 8,699-star action that powers millions of CI/CD workflows daily.

The Pwn Request Problem

GitHub Actions workflows triggered by pull_request_target or workflow_run run with the base repository's GITHUB_TOKEN, secrets, and runner access. If the workflow checks out code from a fork, that fork's code executes with full privileges. Attackers submit a pull request, inject malicious workflow steps, and exfiltrate secrets or push to protected branches.

The attack surface is structural:

  • pull_request_target gives fork PRs access to secrets for commenting and labeling.
  • Checkout v6 and earlier fetched fork code by default.
  • Containerized actions inherit the runner's filesystem, including .git/config.
  • Credentials in .git/config are readable by any process in the container.

Checkout v7 breaks this chain. It refuses to check out fork code unless you explicitly set allow-unsafe-pr-checkout: true. The flag name is not subtle.

Credential Isolation: From .git/config to $RUNNER_TEMP

Before v6, persist-credentials: true wrote the GITHUB_TOKEN directly into .git/config:

[http "https://github.com/"]
    extraheader = AUTHORIZATION: basic <base64-encoded-token>
Enter fullscreen mode Exit fullscreen mode

Any process with filesystem access could read this file. Containerized actions, which mount the workspace, inherited these credentials. If a malicious action ran cat .git/config, it got the token.

Checkout v6 moved credentials into a separate file under $RUNNER_TEMP, a directory that exists only for the lifetime of the job and is not mounted into container actions by default. The .git/config now references the credential file indirectly:

[credential]
    helper = store --file=/runner/_temp/credential_store
Enter fullscreen mode Exit fullscreen mode

The credential helper protocol reads from the temp file only when Git needs authentication. The file is deleted during post-job cleanup. This breaks the attack where a containerized action reads credentials from a predictable location.

Why $RUNNER_TEMP Matters

$RUNNER_TEMP is ephemeral and scoped to the job. It is not part of the workspace directory tree, so actions that mount $GITHUB_WORKSPACE do not see it. This creates a privilege boundary: only the runner process and actions explicitly granted access can read the credential file.

The trade-off is compatibility. Docker container actions that need to run authenticated Git commands now require Actions Runner v2.329.0 or later, which knows how to pass the credential helper context into the container. Older runners fail silently or fall back to unauthenticated operations.

The ESM Migration and Dependency Security

Checkout v7 migrated from CommonJS to ECMAScript Modules (ESM). This was not a stylistic choice. The @actions/* packages (toolkit, core, exec, io) moved to ESM to support modern Node.js runtimes and drop unmaintained transitive dependencies.

The migration forced a full rewrite of the action's entry point and credential handling logic. The new codebase is 362KB of TypeScript compiled to ESM, with updated dependencies that patch known vulnerabilities in older versions of @actions/http-client and tunnel.

The security benefit is indirect but real: ESM's stricter module resolution prevents certain classes of supply chain attacks where an attacker injects a malicious CommonJS module into node_modules and relies on Node's loose resolution order to shadow a legitimate package.

Architecture: How Checkout Decides What to Fetch

Checkout v7 uses a decision tree based on the workflow trigger and the allow-unsafe-pr-checkout flag:

Trigger Default Behavior With allow-unsafe-pr-checkout: true
pull_request Checks out PR merge commit Same (no change)
pull_request_target Refuses to check out fork code Checks out fork code (unsafe)
workflow_run Refuses to check out fork code Checks out fork code (unsafe)
push, schedule, etc. Checks out triggering ref Same (no change)

The refusal is implemented as an early exit in the action's main function. If the trigger is pull_request_target or workflow_run and the flag is not set, the action logs a warning and exits without cloning the repository.

This breaks workflows that relied on implicit fork checkout behavior. The migration path is to audit the workflow for secret exposure, then set the flag if the risk is acceptable.

State Isolation in Multi-Tenant Runners

GitHub's hosted runners are multi-tenant. Jobs from different repositories run on the same VM, separated by ephemeral job directories and process isolation. The credential file in $RUNNER_TEMP is scoped to the job's temp directory, which is cleaned up after the job completes.

Self-hosted runners have different isolation guarantees. If you run multiple jobs concurrently on the same self-hosted runner, they share the same $RUNNER_TEMP parent directory. The credential file is named with a unique job ID, but the directory itself is not isolated. A malicious action could enumerate $RUNNER_TEMP and read credential files from other jobs.

The mitigation is to run self-hosted runners in single-job mode or use ephemeral runners that are destroyed after each job. Checkout v7 does not enforce this. It assumes the runner environment provides job-level isolation.

Failure Modes and Observability Gaps

Checkout v7 fails silently in several scenarios:

  • Old runner version: If the runner is older than v2.329.0 and a containerized action tries to run git push, the credential helper fails and Git prompts for credentials interactively. The workflow hangs until it times out.
  • Missing flag on fork PR: If a workflow expects to check out fork code but does not set allow-unsafe-pr-checkout: true, the action exits early. The workflow continues, but subsequent steps that depend on the repository fail with "directory not found" errors.
  • Credential file race: If a workflow runs multiple checkout steps in parallel (e.g., checking out multiple repositories), they share the same $RUNNER_TEMP directory. The credential file is overwritten by the last checkout step, breaking authentication for earlier checkouts.

GitHub does not expose metrics for these failures. You only see them in workflow logs, and only if you know to look for credential helper errors or missing directory warnings.

Technical Verdict

Use Checkout v7 if you run workflows triggered by pull_request_target or workflow_run and want to prevent fork code from accessing secrets. The credential isolation in $RUNNER_TEMP is a strict improvement over .git/config for containerized actions.

Avoid Checkout v7 if you rely on fork PR checkout behavior without auditing your workflows for secret exposure. The breaking change is intentional, but it will break CI pipelines that assume fork code is checked out by default.

Do not use allow-unsafe-pr-checkout: true unless you have reviewed every step in the workflow and confirmed that no secrets are exposed to fork code. The flag exists for compatibility, not safety.

If you run self-hosted runners, ensure they are ephemeral or run in single-job mode. The credential file isolation assumes job-level directory cleanup, which is not guaranteed on long-lived runners.

Source Links

Top comments (0)