`path` checkout follows a repository-controlled symlink outside `GITHUB_WORKSPACE`
Summary
actions/checkout v7.0.1 validates the requested checkout path lexically, but
does not validate the physical path after existing symlinks are resolved. A
repository checked out first can therefore control an intermediate component
of a later checkout destination.
Minimal workflow shape:
- uses: actions/checkout@v7
# This repository contains: private -> ../outside
- uses: actions/checkout@v7
with:
repository: trusted-org/trusted-repository
path: privateThe second path is accepted because workspace/private is lexically below
$GITHUB_WORKSPACE. The checkout implementation then follows the symlink.
With clean: true, it can remove entries in the physical target, initialize a
Git repository there, and materialize the trusted repository outside the
declared workspace.
The same primitive also reaches protected metadata: a repository entry
metadata -> .git lets a later path: metadata checkout remove the original
checkout's .git contents and create a nested repository below it.
Affected source
The report is based on actions/checkout v7.0.1, source commit
f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a.
The exact source locations are:
src/input-helper.ts, lines 39–53 resolves the input and applies a string-prefix containment check.src/fs-helper.ts, lines 3–10 usesfs.statSync, which follows an existing intermediate symlink.src/git-source-provider.ts, lines 25–35 checks and creates the supplied path.src/git-source-provider.ts, lines 66–75 passes that same path to existing-directory preparation.src/git-directory-helper.ts, lines 117–123 removes directory entries through the uncanonicalized path.src/git-source-provider.ts, lines 108–131 initializes the repository at the same path.
There is no realpath check between the lexical guard and the cleanup/Git
operations.
Reproduction
The attached reproducer uses the real getSource() implementation. It creates
three local repositories: a malicious repository containing the symlinks, a
trusted repository, and a regular-directory control.
From the directory containing this report:
git clone https://github.com/actions/checkout.git attachments/vendor/actions-checkout
git -C attachments/vendor/actions-checkout checkout --detach f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a
(cd attachments/vendor/actions-checkout && npm ci --ignore-scripts && npm run build)
ACTION_CHECKOUT_DIR=attachments/vendor/actions-checkout \
attachments/reproduce.sh attachments/replayThe script refuses to overwrite an existing output directory. It requires Node.js, Git, and the pinned action checkout. The source tree is only used to build the action; all Git operations in the test are performed by the action's own source provider and a native Git binary.
See attachments/evidence.md and
attachments/run.log. The log contains only redacted
relative fixture names and no credentials.
Expected behavior
Every checkout destination should remain physically below the workspace root, and cleanup should cover every filesystem effect of the checkout. A path that contains a repository-controlled symlink should either be rejected or be resolved and checked against the workspace and protected metadata boundaries before cleanup, initialization, fetch, or checkout begins.
Observed behavior
The control checkout resolves to an ordinary directory below the workspace. The attack checkout reports success but produces the following independent filesystem results:
control_inside=true
requested=workspace/private
resolved=outside
preexisting_file_removed=true
outside_repository_survives=true
metadata_sentinel_removed=true
main_git_config_removed=true
nested_repository_published=true
content=trusted-checkout-contentThe external repository remains after the workspace symlink is removed. This
means a cleanup rooted at $GITHUB_WORKSPACE does not reclaim the materialized
trusted repository.
Security impact and prerequisites
The direct impact is a physical-root and cleanup-boundary violation:
- an untrusted repository controls the destination of a later trusted checkout;
- the later checkout can delete a pre-existing file at the physical target;
- trusted source and
.gitmetadata can be written outside the declared checkout directory; and - workspace-rooted cleanup can leave the materialization behind.
This matters for self-hosted runners and containerized jobs that treat
$GITHUB_WORKSPACE as a writable, disposable boundary. The sequence requires
the first repository to be materialized before the later checkout uses the
colliding nested path, and the physical target must be writable by the runner.
This report does not claim host compromise or standalone arbitrary code execution. The demonstrated security property is confinement and cleanup of a trusted checkout in a multi-checkout workflow.
Related history and novelty
The closest public records are not the same behavior:
- #1812 requests support for
paths outside
$GITHUB_WORKSPACE; it does not describe a symlink bypass of the documented boundary. - #2393 concerns a symlinked
runner
_workdirectory and credentialincludeIfmatching. It does not involve a repository-controlled intermediate component or a later checkout redirected outside the workspace.
I did not find an actions/checkout issue or advisory describing this intermediate-symlink/later-checkout sequence. The attached run is therefore a new, source-realized report against the pinned release, subject to the maintainers' interpretation of the workspace boundary for self-hosted runners.
Suggested remediation
Resolve the candidate destination with realpath-style semantics after
accounting for existing components, then enforce that the canonical path is
inside the canonical workspace and outside protected runner state. Apply the
same check immediately before cleanup and before passing the path to Git. Add
regression tests for:
- a committed intermediate symlink to a sibling directory;
- a committed intermediate symlink to
.git; - cleanup after the checkout; and
- nested trusted checkouts following an untrusted checkout.
Attachments
Source: actions/checkout