Fix GNU tar path matching issue

This commit is contained in:
Jason Ginchereau
2026-06-22 09:48:00 -10:00
parent a41517f52a
commit 3690eeb098
2 changed files with 13 additions and 19 deletions
+4 -2
View File
@@ -208,8 +208,10 @@ and bsdtar on macOS (and `tar.exe` on Windows):
- `'error'` mode, clean archive → every approved member is extracted - `'error'` mode, clean archive → every approved member is extracted
- `'error'` mode, PAX path newline archive → throws `CacheIntegrityError` and - `'error'` mode, PAX path newline archive → throws `CacheIntegrityError` and
writes nothing to the workspace writes nothing to the workspace
- `'error'` mode, leading `./` entry → still extracted (the `-T` allow-list - `'error'` mode, leading `./` entry → still extracted; the `-T` allow-list
uses the canonical `cache/f` name, so the member is not silently skipped) uses the member name verbatim (exactly as node-tar derived it from the
archive), which is what GNU tar matches anchored/exact — stripping the `./`
would make GNU tar report "Not found in archive"
- `'error'` mode, long path (> 100 bytes) delivered via PAX → extracted, not - `'error'` mode, long path (> 100 bytes) delivered via PAX → extracted, not
dropped by the allow-list (exercises long-name matching in `-T`) dropped by the allow-list (exercises long-name matching in `-T`)
+9 -17
View File
@@ -431,11 +431,16 @@ function writeAllowList(approvedNames: string[]): string {
.randomBytes(8) .randomBytes(8)
.toString('hex')}.lst` .toString('hex')}.lst`
) )
// Write each approved name exactly as node-tar derived it from the archive
// bytes. That is the same name system tar reads from the archive header, so
// an anchored `-T` match succeeds for every member without us second-guessing
// tar's name handling. In particular we must NOT strip a leading `./`: GNU
// tar matches `-T` names anchored and exact and keeps the `./`, so a
// `cache/f` pattern does not match a `./cache/f` member (it fails with
// "Not found in archive", exit code 2) whereas the verbatim `./cache/f`
// matches and extracts to `cache/f`.
const payload = Buffer.concat( const payload = Buffer.concat(
approvedNames.flatMap(name => [ approvedNames.flatMap(name => [Buffer.from(name, 'utf8'), Buffer.from([0])])
Buffer.from(canonicalMemberName(name), 'utf8'),
Buffer.from([0])
])
) )
// `flag: 'wx'` (O_CREAT | O_EXCL | O_WRONLY) makes the open fail if the path // `flag: 'wx'` (O_CREAT | O_EXCL | O_WRONLY) makes the open fail if the path
// already exists, so a file or symlink pre-planted at the (randomized) temp // already exists, so a file or symlink pre-planted at the (randomized) temp
@@ -446,19 +451,6 @@ function writeAllowList(approvedNames: string[]): string {
return allowListPath return allowListPath
} }
/**
* Canonicalize an approved entry name to the form system `tar` matches against
* its archive members when reading the `-T` allow-list. node-tar already
* converts backslashes to forward slashes; here we additionally strip any
* leading `./` (both GNU tar and bsdtar normalize member names this way) so an
* entry node-tar surfaced as `./cache/f` still matches the member `cache/f`
* and is not silently skipped during extraction. A trailing slash on a
* directory entry is preserved because tar matches directories with it.
*/
function canonicalMemberName(name: string): string {
return name.replace(/^(?:\.\/)+/, '')
}
function reportViolations( function reportViolations(
violations: PathValidationViolation[], violations: PathValidationViolation[],
mode: PathValidationMode mode: PathValidationMode