Skip to content

checkout: refuse outputs that resolve outside the repository - #11105

Open
ompatil1906 wants to merge 1 commit into
treeverse:mainfrom
ompatil1906:fix/checkout-symlink-escape
Open

ompatil1906 wants to merge 1 commit into
treeverse:mainfrom
ompatil1906:fix/checkout-symlink-escape

Conversation

@ompatil1906

Copy link
Copy Markdown

Summary

dvc checkout / dvc pull can materialize a tracked output through an
intermediate symlink, allowing arbitrary file writes outside the clone root
and installation of executable Git hooks (code execution on the next
git commit in CI that pulls an untrusted repository).

Public issue: #11101

Problem

Output.is_in_repo / fs.isin containment is performed on the declared
lexical path (commonpath), while the OS resolves symlinks at write time
inside dvc_data.index.checkout.apply. A repo that contains
outlink -> ../outside with an output declared as outlink/payload.bin, or
hookdir -> .git/hooks with hookdir/pre-commit, can - with perfectly valid
Git tree, DVC descriptor and remote object - cause the fetched object to land
outside the project root or as an executable hook under .git/hooks.

Root cause

The containment check never establishes that the canonical filesystem
destination stays inside the project. The temporary-file creation and
os.replace performed by dvc-objects follow the intermediate symlinks, and
no physical-root or protected-.git check exists at the materialization
boundary.

Fix

Add a canonical containment gate at the checkout boundary in
dvc/repo/checkout.py:

  • realpath both sides (output destination and repo root / .git).
  • Validate the output root and every tree-entry key of directory outputs.
  • Reject destinations that resolve outside the repository root or inside
    root/.git with a loud, clear CheckoutError - never a silent skip,
    independent of --force.
  • The gate covers both dvc pull and dvc checkout, which converge here.

Behavior change note: outputs whose canonical destination resolves outside
the repository (or inside .git) are now rejected with a clear error. This
deliberately breaks the unusual-but-legal setup "output path crosses a symlink
to outside" - loudly rather than silently.

Testing

  • 5 new regression tests in tests/func/test_checkout.py:
    • output escaping via parent symlink to outside;
    • output escaping into .git/hooks via parent symlink;
    • output under an in-repo symlink still allowed (no regression);
    • in-directory tree-entry symlink escaping to .git;
    • in-directory tree-entry symlink escaping outside.
  • All 4 rejection tests fail pre-fix ("DID NOT RAISE") and pass post-fix.
  • pytest tests/func/test_checkout.py - 53 passed.
  • Unit suite - 7 passed.
  • ruff check and ruff format --check clean.
  • End-to-end harness from the issue (reproduce.sh) rerun: control
    materializes; the escape and .git/hooks attacks are blocked with the new
    error messages and no artifact is written inside or outside; the
    in-directory tree-entry bypass variant is also blocked (dvc pull rc 1).

Follow-ups (out of scope)

  • Per-stage materialization paths (dvc run/repro run-cache restore,
    sync/import) and dvc-data/dvc-objects still perform raw
    symlink-following writes; consider moving the guard into
    dvc_data.index.checkout.apply for defense-in-depth, and evaluate a
    security advisory for the .git/hooks code-execution chain.
  • TOCTOU (attacker swapping symlinks mid-checkout) is out of scope for the
    untrusted-repo CI scenario.

Fixes #11101


Thank you for the contribution - we'll try to review it as soon as possible. 🙏

Fixes treeverse#11101

Signed-off-by: Om Patil <patilom1906@gmail.com>
@github-project-automation github-project-automation Bot moved this to Backlog in DVC Sep 18, 2026
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@codecov

codecov Bot commented Sep 18, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.64706% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.99%. Comparing base (2431ec6) to head (4ce2ad6).
⚠️ Report is 213 commits behind head on main.

Files with missing lines Patch % Lines
dvc/repo/checkout.py 92.85% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #11105      +/-   ##
==========================================
+ Coverage   90.68%   90.99%   +0.31%     
==========================================
  Files         504      505       +1     
  Lines       39795    41223    +1428     
  Branches     3141     3270     +129     
==========================================
+ Hits        36087    37511    +1424     
- Misses       3042     3072      +30     
+ Partials      666      640      -26     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ompatil1906

Copy link
Copy Markdown
Author

Heads-up on the failing lint jobs: the mypy error is pre-existing on main and unrelated to this change — it is in dvc/commands/completion.py (shtab typing drift: Argument "preamble" to "complete" has incompatible type "dict[str, str]"; expected "str"). The same lint failure is present on other open PRs today (#11102, #11100), and this PR's diff touches only dvc/repo/checkout.py and tests/func/test_checkout.py. Happy to rebase if the dependency is pinned/fixed on main.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

dvc pull can materialize a tracked output through an intermediate symlink

2 participants