fix: block symlink-follow output escapes + assorted correctness fixes#161
Open
oceanplexian wants to merge 8 commits into
Open
fix: block symlink-follow output escapes + assorted correctness fixes#161oceanplexian wants to merge 8 commits into
oceanplexian wants to merge 8 commits into
Conversation
The lexical path check does not stop writes through symlinks already present in the output folder: os.MkdirAll and os.OpenFile follow them, landing archived data outside the output folder. - resolveExisting() resolves symlinks in the deepest existing portion of a path and re-appends the not-yet-created tail. - mkDir() now verifies the resolved folder stays inside the resolved output folder (covers every extractor, all funnel through mkDir). - writeFile() removes a pre-existing symlink at the write target instead of truncating through it (O_TRUNC follows links). - createHardLink() also validates the resolved link target.
File entries in ISO and UDF images are checked against the output folder, but directory entries were joined and created unchecked, so a crafted image could create (and then populate) directories outside the output folder. Route both through clean() + pathWithinOutput, matching the file-entry guards.
A crafted CUE sheet with FILE "../../secret.flac" resolved outside the cue folder, letting an archive read and copy sibling files into the extraction output. Reject FILE entries that leave the cue folder.
The password retry loops built a bare XFile per attempt, dropping the logger, progress callbacks, SquashRoot, FileWorkers (rar) and the wired moveFiles helper. Copy the caller's XFile and only swap the password.
The dispatch loop read firstErr while worker goroutines wrote it under sync.Once - an unsynchronized read the race detector flags when a worker fails mid-dispatch. Replace both duplicated pools with one generic dispatchWorkers helper that shares the first error via atomic.Pointer.
The cross-device rename fallback leaked oldFile when openFile or io.Copy failed, and applied Chtimes to the pre-truncation path.
A single Read may return short data, missing signatures stored deeper in the file (ISO at 0x8001+). Also stop printing 'extension error: <nil>' in the fallback debug line when no extension matched.
Unlike tar links, cpio symlink/hard-link entries were created without ensuring the parent folder exists, failing on archives that omit explicit directory entries.
oceanplexian
force-pushed
the
fix/path-escape-and-misc
branch
from
July 16, 2026 20:02
a68b864 to
c2b3547
Compare
Contributor
There was a problem hiding this comment.
Pull request overview
This PR hardens extraction against output-directory escapes (especially via pre-existing symlinks) and applies several correctness fixes across extractors and supporting utilities, with regression tests covering the newly addressed cases.
Changes:
- Prevent writes escaping
OutputDirvia pre-existing symlinks by adding resolved-path containment checks and replacing final-component symlinks at write targets. - Apply consistent path validation for ISO9660/UDF directory entries and restrict CUE
FILEreferences to remain within the CUE sheet’s folder. - Improve correctness and robustness: preserve
XFileconfig across RAR/7z password retries, introduce a shared parallel worker dispatcher, fix signature detection reads, and patch smaller extraction edge cases (e.g., cpio link parent dirs, Rename fallback behaviors).
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| zip.go | Switch ZIP parallel extraction to shared dispatchWorkers helper. |
| 7z.go | Switch 7z parallel extraction to shared dispatchWorkers helper; preserve config across password retries. |
| files.go | Add dispatchWorkers, resolved-path containment helpers, symlink-safe mkDir/writeFile, logging tweak, and Rename fallback fixes. |
| udf.go | Validate and guard UDF directory entry paths via clean() + pathWithinOutput. |
| iso.go | Validate and guard ISO directory entry paths via clean() + pathWithinOutput. |
| cue.go | Reject CUE FILE references that escape the cue directory; remove now-unneeded lint suppression. |
| cpio.go | Ensure parent directory exists for cpio link entries before creating the link. |
| magic.go | Use io.ReadFull to avoid short-read signature misses. |
| symlink_test.go | Add regression tests for pre-existing symlink directory escape and final-component symlink replacement. |
| parallel_test.go | Add coverage for worker-phase error propagation in parallel ZIP extraction. |
| rar.go | Preserve full caller configuration across password retries by copying XFile per attempt. |
| rar_test.go | Add regression test ensuring password retries keep caller logger/config. |
| cue_internal_test.go | Add internal tests for CUE traversal rejection and valid nested audio paths. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+844
to
+849
| // mkDir creates a folder (and parents) with safe permissions. | ||
| // It refuses to leave the output folder through a pre-existing symlink. | ||
| func (x *XFile) mkDir(path string, mode os.FileMode, mtime time.Time) error { | ||
| defer os.Chtimes(path, time.Time{}, mtime) | ||
| return os.MkdirAll(path, x.safeDirMode(mode)) //nolint:wrapcheck | ||
|
|
||
| err := os.MkdirAll(path, x.safeDirMode(mode)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bug-fix pass over the extractors, focused on path traversal / output-folder escapes. Each commit is small and self-contained; the three
security:commits are the headline, the rest are correctness fixes found along the way. All changes include regression tests where the bug is practically reproducible.Security fixes
1. Symlink-follow escapes in the output folder (
security: block symlink-follow escapes)The lexical path checks from #157/#159 don't stop writes through symlinks already present in the output folder:
os.MkdirAllandos.OpenFilefollow them. Two live variants, both reproduced before fixing:Since every extractor funnels through
mkDir+writeFile, the fix lives in those two choke points:resolveExisting()resolves symlinks in the deepest existing portion of a path and re-appends the not-yet-created tail (also normalizes OutputDir itself, e.g./var->/private/varon macOS).mkDirrejects any resolved folder that lands outside the resolved OutputDir.writeFileremoves a pre-existing symlink at the write target instead of truncating through it.createHardLinknow validates the resolved target too.Note:
MkdirAllcan still create empty dirs through a hostile symlink before detection (component-wise secure mkdir is a bigger change); no file data is written outside anymore.2. ISO9660/UDF directory paths unchecked (
security: validate ISO9660/UDF directory paths)File entries in ISO/UDF images get the
pathWithinOutputguard, but directory entries were joined + created raw. A crafted image could create and then populate directories outside the output folder. Both now go throughclean()+pathWithinOutput, matching the file-entry guards.3. CUE sheet
FILEescape (security: keep CUE-referenced audio inside the cue folder)A CUE sheet with
FILE "../../secret.flac"resolved outside the cue folder, letting a crafted archive read and copy sibling files into the extraction output.resolveCueAudioPathnow rejects entries that leave the cue folder (nested subfolders still work).Correctness fixes
XFileper attempt, silently losing the logger, progress callbacks,SquashRoot,FileWorkers(rar) andmoveFiles. Now copies the caller'sXFileand swaps only the password.firstErrwhile workers wrote it undersync.Once; flagged by-racewhen a worker fails mid-dispatch. Both duplicated pools are replaced with one genericdispatchWorkershelper usingatomic.Pointer.Renamecopy fallback leaked the source fd onopenFile/io.Copyerrors, and appliedChtimesto the pre-truncation path.Read— short reads could miss deeper signatures (ISO at 0x8001+). Nowio.ReadFull. Also stops loggingextension error: <nil>when no extension matched.Testing
go test -race ./...passes;go vetandgolangci-lint runclean (one pre-existingpreallocnit inchardet.goon main).Notes for review
ErrInvalidPathis reused for the new rejections).