fix(action): block symlink escape past GITHUB_WORKSPACE - #5
Conversation
runConfdiff() reads the new side of a changed file straight off the working tree, which follows symlinks. A PR that retargets an already-tracked symlink to point outside the checkout (e.g. a repo that legitimately symlinks shared config) could have its target's contents read and posted in the sticky PR comment. Canonicalize each changed file's path with realpathSync and skip (with a visible warning, same pattern as parse errors) any file that resolves outside GITHUB_WORKSPACE. This replaces the CLI-side "..".-segment check proposed in PR esperanza-volkov#4, per discussion there: the CLI must keep supporting legitimate relative paths, git never tracks ".."-containing paths so that check was dead code on the Action's actual input, and the real residual vector is this Action-side symlink read. Refs: esperanza-volkov#4 (comment) esperanza-volkov#4 (comment) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
esperanza-volkov
left a comment
There was a problem hiding this comment.
Reviewed and reproduced end-to-end. Confirmed the residual vector is real for lenient formats: with a config tracked as a symlink at base (e.g. config.csv), git show base: returns the link text which a strict parser (JSON) rejects — but CSV/.env parse it fine, so retargeting the symlink to an out-of-workspace file (/tmp/secret.csv) leaked its contents straight into the sticky PR comment. Your realpathSync(f) containment check (skip-with-warning, using workspace + sep to avoid the /foo vs /foobar prefix trap) blocks it cleanly, and the two new tests plus the full suite (123/123) confirm no regression for normal in-workspace changes. Correctly scoped to the Action boundary with src/cli.ts untouched, exactly as discussed. Nicely done — thank you.
Context
Follow-up to #4, which was closed after discussion:
Conclusion of that thread:
readInput()should not reject relative..paths — that's legitimate CLI usage (../staging/...etc.), and rejecting it would regress it...traversal isn't reachable via the Action anyway —changedFiles()only ever passesgit diff --name-onlyoutput to confdiff, and git refuses to track paths containing...runConfdiff(oldPath, f, …)reads the new side straight off the working tree atf. If a file tracked as a symlink has its target retargeted to point outsideGITHUB_WORKSPACE(e.g. a repo that legitimately tracks a symlinked shared config, and a PR repoints it to something like/etc/passwd), readingffollows the symlink and the target's contents can end up in the sticky, public PR comment.I verified the mechanics with real git repos: a plain file turning into a symlink between base/head is a git type-change (
T), which the Action's existing--diff-filter=Malready excludes. But a file already tracked as a symlink, with only its target changed in the PR, is reported as a normalM— and does reachrunConfdiff. That's the exploitable path this PR closes.Fix
action/index.mjs: before reading a changed file's new-side content,realpathSync(f)and skip (with a visible warning, same pattern as existing parse errors) any file whose resolved path isn't insiderealpathSync(GITHUB_WORKSPACE).No changes to
src/cli.ts/readInput()— intentionally out of scope per the discussion above.Test plan
npm test— all existing tests pass, plus two new cases intest/action-symlink.test.ts:🤖 Generated with Claude Code