Skip to content

ci(lockfix): refuse a lock path that resolves through a symlink - #1374

Merged
mforce merged 3 commits into
mainfrom
fix/1373-lockfix-symlink
Oct 27, 2026
Merged

mforce merged 3 commits into
mainfrom
fix/1373-lockfix-symlink

Conversation

@mforce

@mforce mforce commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

What and why

Closes #1373.

lockfix-apply.sh checked each artifact file, but not the destination pr/$rel, which comes from the PR checkout. If the PR tree commits a lock path, or one of its parent directories, as a symlink, cp writes through it. With a compromised compute job supplying the artifact, that can overwrite trusted/.github/scripts/lockfix-apply.sh while bash is still running it, inside the commit job that holds the App token. A local review of the collectify port of this workflow (mforce/collectify#225) found the hole and proved it in a sandbox. This PR ports collectify's fix.

  • Before each copy, lockfix-apply.sh refuses unless realpath -m "pr/$rel" equals $(pwd -P)/pr/$rel, so no symlink can sit anywhere on the path. It also refuses unless the lock is tracked as a regular file with mode 100644 (the exact-entry form is described below).
  • lockfix.mjs's run-as-CLI check now compares real paths. Before, a call through a symlink skipped main() and exited 0, and the apply script then treated 0 as "commit"; the second commit below changes that.
  • The design spec notes the destination check. The sentence that described the allowlist as 7 files now gives no count, because the list has 10.
  • Commit now has its own exit code, 4. lockfix-apply.sh commits only on 4, so a classifier that exits 0 without running main() makes it refuse. Before, exit 0 meant commit, so any way of skipping main() (a call through a symlink, or node --preserve-symlinks-main) approved a commit. The header comment and the spec's exit-code list say so.
  • The mode check reads the exact tree entry, git ls-tree --format='%(objectmode) %(path)' HEAD -- "$rel", which must be exactly 100644 <path>. The old git ls-files -s matched a path prefix, so a directory at a lock path holding one regular file passed it. The classifier still refused that case later.
  • The tests share one scratch root that an after() hook removes, so they no longer leave temp directories behind.

One correction to #1373: it says the apply script already rejects a symlinked artifact file. It doesn't; it checks only -f, which follows symlinks. This PR leaves that alone, because the script only reads the artifact file, so a symlinked source can't make it write anywhere.

How it was verified

  • node --test .github/scripts/lockfix.test.mjs printed 17 passed, 0 failed, after the second commit. With the first commit alone it printed 15 passed. Seven tests are new: five in the first commit, and two in the second (an exit-0 stub classifier, and a directory at the lock path). Of the first five, four run the apply script in a scratch git repo with a local bare remote: a normal run that commits and pushes, a lock file committed as a symlink, a working-tree symlink over a 100644 entry, and a lock file tracked as 100755. The fifth runs the CLI through a symlink.
  • Mutations, each run against the green baseline:
    • Without the real-path check, the working-tree symlink test went red.
    • Without the mode check, the 100755 test went red.
    • The committed-symlink test went red only with both checks removed, because either one alone stops it.
    • Reverting the CLI real-path comparison turned the symlink CLI test red.
    • Making the apply script accept exit 0 as well as 4 turned the new exit-0 stub test red.
    • Going back to ls-files for the mode check turned the new directory test red.
  • Attack replay, run locally. At eec4d136, a lock path committed as a symlink to the trusted script let the artifact overwrite lockfix-apply.sh, and the script exited 0. With this change, it aborts with "lock path goes through a symlink". The trusted script's hash stays the same, and nothing reaches the remote.
  • A local review re-ran the attack in five more layouts: a directory symlink into trusted/ (committed, or only in the working tree), src/ as a symlink, pr/ as a symlink, and a symlink to another directory in the PR. Every layout stopped before cp, with nothing pushed. The review found no defects.
  • A second local review covered the second commit. It confirmed that every reader of the exit code agrees on 4, ran the ls-tree check on a scratch repo (it accepts a normal lock and refuses a directory, a symlink, 100755, a gitlink, a missing path, a trailing slash and a glob), and ran the suite with an empty TMPDIR, leaving nothing behind. --format needs a recent git; the reviewer judged it safe because both lockfix jobs pin ubuntu-26.04, and this PR's CI runs the new tests, which call it, on a hosted runner. It found no defects.
  • shellcheck and actionlint are clean on the changed files.

Checklist

  • The PR title is a conventional commit, and is the release note I want.
  • Tests cover the change. Each guard's test goes red when that guard is removed (see the mutations above).
  • Any new guard was mutation-checked: the mutations above, run against a green baseline.
  • No hardcoded credential, in application or test code.
  • No hardcoded hosting-provider name in code, config, or a committed doc.

lockfix-apply.sh copied each regenerated lock onto pr/$rel without
checking the destination, which the PR tree controls. A lock path
committed as a symlink made cp write through it, for example over the
trusted apply script while bash was still running it, in the job that
holds the App token. Before each copy, refuse unless the resolved path
stays at pr/$rel and the index entry is mode 100644.

lockfix.mjs skipped main() and exited 0 ("commit") when invoked through
a symlink, because the run-as-CLI check compared the unresolved argv
path. Compare the realpath instead.

Tests run the apply script in a scratch git layout for a committed
symlink, a working-tree symlink and a 100755 entry, and run the CLI
through a symlink. Ported from mforce/collectify#225.
@mforce

mforce commented Oct 27, 2026

Copy link
Copy Markdown
Owner Author

A code-quality review of this PR at 33d843a found three things to fix before merge. A fix is in progress, so please hold the merge.

  • The classifier still exits 0 for "commit", which is also what Node returns when main() never runs. The symlink fix closes one way to reach that, and node --preserve-symlinks-main is another. Commit will get its own exit code, and the apply script will accept only that code.
  • The mode check (ls-files -s) matches a path prefix, so a directory at a lock path can pass it. The classifier still refuses that case later, so it is not a hole, but the check will move to the exact entry.
  • The new tests leave their temp directories behind.

The realpath destination check itself is correct.

@mforce

mforce commented Oct 27, 2026

Copy link
Copy Markdown
Owner Author

The review findings from my earlier comment are fixed in 2c7e754, and the PR description now covers that commit. Commit exits 4 and the apply script commits only on 4. The mode check reads the exact tree entry, and the tests clean up their temp directories. A second local review round on that commit found no defects. The technical reason for the hold is gone once CI at 2c7e754 is green; the merge itself is still my call.

@mforce
mforce merged commit 2040711 into main Oct 27, 2026
18 checks passed
@mforce
mforce deleted the fix/1373-lockfix-symlink branch October 27, 2026 07:01
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.

Dependabot lockfix: cp follows a symlink at the lock destination

1 participant