Improve path validations and test coverage

This commit is contained in:
Jason Ginchereau
2026-06-19 15:35:16 -10:00
parent bb827dbfd4
commit a41517f52a
8 changed files with 358 additions and 182 deletions
+7 -5
View File
@@ -979,10 +979,11 @@ describe('validateEntry', () => {
}) })
}) })
describe('control characters in filenames (legal but unusual)', () => { describe('control characters in filenames', () => {
test('embedded newline is treated literally as part of the segment', () => { test('embedded newline is rejected as an unsafe control character', () => {
// node-tar reports the full filename including the embedded newline. // A newline in a member name would corrupt a newline-delimited tar file
// It's a single segment under node_modules — must be accepted. // list and enables log / line injection, so it is rejected even though
// it is a single segment under node_modules.
const r = validateEntry( const r = validateEntry(
'node_modules/file\nwith newline', 'node_modules/file\nwith newline',
undefined, undefined,
@@ -990,7 +991,8 @@ describe('validateEntry', () => {
allowedRoots, allowedRoots,
cwd cwd
) )
expect(r.ok).toBe(true) expect(r.ok).toBe(false)
if (!r.ok) expect(r.code).toBe('UNSAFE_CHAR')
}) })
test('embedded tab is accepted', () => { test('embedded tab is accepted', () => {
+32 -9
View File
@@ -23,17 +23,17 @@ describe('parsePaxLengthCorrect', () => {
}) })
test('newline-in-value: the embedded fake record is swallowed by comment', () => { test('newline-in-value: the embedded fake record is swallowed by comment', () => {
// This is the F2 PAX body. A naive split('\n') parser (node-tar) ends up // An embedded-newline PAX body. A naive split('\n') parser (node-tar) ends
// with path=safe.txt; the length-correct parser must yield the real // up with path=safe.txt; the length-correct parser must yield the real
// (malicious) path and treat the rest as the comment value. // (malicious) path and treat the rest as the comment value.
const buf = Buffer.from( const buf = Buffer.from(
'42 path=../../../../../../tmp/zip_slip_F2\n30 comment=x\n17 path=safe.txt\n', '42 path=../../../../../../tmp/escaped_pax\n30 comment=x\n17 path=safe.txt\n',
'ascii' 'ascii'
) )
const {records, ok} = parsePaxLengthCorrect(buf) const {records, ok} = parsePaxLengthCorrect(buf)
expect(ok).toBe(true) expect(ok).toBe(true)
expect(records['path'].toString('utf8')).toBe( expect(records['path'].toString('utf8')).toBe(
'../../../../../../tmp/zip_slip_F2' '../../../../../../tmp/escaped_pax'
) )
expect(records['comment'].toString('utf8')).toBe('x\n17 path=safe.txt') expect(records['comment'].toString('utf8')).toBe('x\n17 path=safe.txt')
// Crucially NOT safe.txt. // Crucially NOT safe.txt.
@@ -133,16 +133,16 @@ describe('crossCheckMetaBodies', () => {
expect(v).toEqual([]) expect(v).toEqual([])
}) })
test('F2 path desync: node-tar resolved safe.txt, length-correct disagrees', () => { test('PAX path desync: node-tar resolved safe.txt, length-correct disagrees', () => {
const buf = Buffer.from( const buf = Buffer.from(
'42 path=../../../../../../tmp/zip_slip_F2\n30 comment=x\n17 path=safe.txt\n', '42 path=../../../../../../tmp/escaped_pax\n30 comment=x\n17 path=safe.txt\n',
'ascii' 'ascii'
) )
const v = crossCheckMetaBodies([buf], 'safe.txt', undefined) const v = crossCheckMetaBodies([buf], 'safe.txt', undefined)
expect(v.map(x => x.code)).toContain('PAX_DESYNC') expect(v.map(x => x.code)).toContain('PAX_DESYNC')
}) })
test('F2-linkpath desync: node-tar resolved safe/target, length-correct disagrees', () => { test('PAX linkpath desync: node-tar resolved safe/target, length-correct disagrees', () => {
const buf = Buffer.from( const buf = Buffer.from(
'34 linkpath=../../../../../../tmp\n37 comment=x\n24 linkpath=safe/target\n', '34 linkpath=../../../../../../tmp\n37 comment=x\n24 linkpath=safe/target\n',
'ascii' 'ascii'
@@ -162,10 +162,9 @@ describe('crossCheckMetaBodies', () => {
expect(v.map(x => x.code)).toContain('PAX_UNKNOWN_KEY') expect(v.map(x => x.code)).toContain('PAX_UNKNOWN_KEY')
}) })
test('known SCHILY/GNU/LIBARCHIVE prefixed keys are accepted', () => { test('known SCHILY/LIBARCHIVE prefixed keys are accepted', () => {
const records = [ const records = [
'SCHILY.xattr.user.foo=bar', 'SCHILY.xattr.user.foo=bar',
'GNU.sparse.realsize=1024',
'LIBARCHIVE.creationtime=1700000000' 'LIBARCHIVE.creationtime=1700000000'
] ]
const body = records const body = records
@@ -185,6 +184,30 @@ describe('crossCheckMetaBodies', () => {
expect(v).toEqual([]) expect(v).toEqual([])
}) })
test('GNU.sparse.* keys are rejected as PAX_UNSUPPORTED_KEY', () => {
// node-tar v7 does not process GNU sparse keys, so it surfaces the entry
// under its header path while system tar would reconstruct the file at
// `GNU.sparse.name` (here pointing outside the cache roots). The path /
// linkpath cross-check cannot see this, so the sparse namespace must be
// rejected outright even though it is under the broadly-allowed `GNU.`
// prefix.
const body = Buffer.from(
rec('GNU.sparse.major=1') +
rec('GNU.sparse.minor=0') +
rec('GNU.sparse.name=../../../../tmp/evil') +
rec('GNU.sparse.realsize=1024'),
'ascii'
)
const v = crossCheckMetaBodies(
[body],
'cache/GNUSparseFile.0/decoy',
undefined
)
expect(v.map(x => x.code)).toContain('PAX_UNSUPPORTED_KEY')
// It must NOT also be misreported as merely an unknown key.
expect(v.map(x => x.code)).not.toContain('PAX_UNKNOWN_KEY')
})
test('GNU long-name raw body matching entry path: no violation', () => { test('GNU long-name raw body matching entry path: no violation', () => {
// A GNU LongName body is a raw NUL-terminated path with no length prefix. // A GNU LongName body is a raw NUL-terminated path with no length prefix.
const raw = Buffer.concat([ const raw = Buffer.concat([
+137 -41
View File
@@ -16,11 +16,13 @@ import {extractTar} from '../src/internal/tar'
import {CacheIntegrityError} from '../src/internal/cacheIntegrityError' import {CacheIntegrityError} from '../src/internal/cacheIntegrityError'
/** /**
* Parser-differential bypass regression tests. These build the F1 / F2 / * Parser-differential bypass regression tests. Each case builds a malicious
* F2-linkpath / F3 / F5 PoC archives from the security analysis as raw tar * archive as raw tar bytes (so we can craft PAX bodies and typeflags that
* bytes (so we can craft malicious PAX bodies and typeflags that node-tar's * node-tar's Header encoder would never produce) designed to make node-tar's
* Header encoder would never produce) and assert the validator now refuses * in-process listing disagree with the path the system `tar` extractor would
* each one. See docs/zip-slip-* for the analysis. * write to, then asserts the validator refuses it. The vectors covered are: an
* unknown typeflag byte, a PAX `path=` / `linkpath=` record with an embedded
* newline, an oversized PAX header, and a GNU sparse typeflag.
*/ */
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -115,30 +117,33 @@ function paxRecord(content: string): string {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PoC archives // Malicious archives
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// F1 — unknown typeflag byte ('Z') is emitted by node-tar as an ignoredEntry. // Unknown typeflag byte ('Z') is emitted by node-tar as an ignoredEntry, but
const F1 = Buffer.concat([ // system tar would extract it as a regular file.
const unknownTypeflagArchive = Buffer.concat([
fileEntry('cache/safe.txt', 'ok'), fileEntry('cache/safe.txt', 'ok'),
fileEntry('../../../../../../tmp/zip_slip_F1', 'F1 pwned', 'Z'), fileEntry('../../../../../../tmp/escaped_unknown_type', 'pwned', 'Z'),
end() end()
]) ])
// F2 — PAX `path=` newline differential. // PAX `path=` record whose value carries an embedded newline. node-tar's naive
const F2 = Buffer.concat([ // `split('\n')` parse resolves the trailing `path=safe.txt`, while a
// length-correct parse (matching system tar) resolves the escaping path.
const paxPathNewlineArchive = Buffer.concat([
paxEntry( paxEntry(
Buffer.from( Buffer.from(
'42 path=../../../../../../tmp/zip_slip_F2\n30 comment=x\n17 path=safe.txt\n', '42 path=../../../../../../tmp/escaped_pax\n30 comment=x\n17 path=safe.txt\n',
'ascii' 'ascii'
) )
), ),
fileEntry('cache/safe.txt', 'F2 pwned'), fileEntry('cache/safe.txt', 'pwned'),
end() end()
]) ])
// F2-linkpath — same differential, applied to a symlink's `linkpath=`. // The same embedded-newline differential applied to a symlink's `linkpath=`.
const F2L = Buffer.concat([ const paxLinkpathNewlineArchive = Buffer.concat([
paxEntry( paxEntry(
Buffer.from( Buffer.from(
'34 linkpath=../../../../../../tmp\n37 comment=x\n24 linkpath=safe/target\n', '34 linkpath=../../../../../../tmp\n37 comment=x\n24 linkpath=safe/target\n',
@@ -149,23 +154,24 @@ const F2L = Buffer.concat([
end() end()
]) ])
// F3 — oversized PAX header (> 1 MiB) is dropped by node-tar's // Oversized PAX header (> 1 MiB) is dropped by node-tar's maxMetaEntrySize and
// maxMetaEntrySize and would otherwise let a `path=` override slip through. // would otherwise let the `path=` override slip through unseen.
const F3 = Buffer.concat([ const oversizedPaxHeaderArchive = Buffer.concat([
paxEntry( paxEntry(
Buffer.concat([ Buffer.concat([
Buffer.from(`1048600 comment=${'A'.repeat(1048600 - 17)}\n`, 'ascii'), Buffer.from(`1048600 comment=${'A'.repeat(1048600 - 17)}\n`, 'ascii'),
Buffer.from('42 path=../../../../../../tmp/zip_slip_F3\n', 'ascii') Buffer.from('42 path=../../../../../../tmp/escaped_big\n', 'ascii')
]) ])
), ),
fileEntry('cache/safe.txt', 'F3 pwned'), fileEntry('cache/safe.txt', 'pwned'),
end() end()
]) ])
// F5 — sparse typeflag 'S' is mapped but ignored by node-tar's ReadEntry. // GNU sparse typeflag 'S' is mapped but ignored by node-tar's ReadEntry, while
const F5 = Buffer.concat([ // system tar would extract it.
const sparseTypeflagArchive = Buffer.concat([
fileEntry('cache/decoy.txt', 'ok'), fileEntry('cache/decoy.txt', 'ok'),
fileEntry('../../../../../../tmp/zip_slip_F5', '', 'S'), fileEntry('../../../../../../tmp/escaped_sparse', '', 'S'),
end() end()
]) ])
@@ -228,33 +234,50 @@ afterAll(() => {
}) })
describe('listAndValidate: parser-differential bypass detection', () => { describe('listAndValidate: parser-differential bypass detection', () => {
test('F1: unknown typeflag is rejected as UNSUPPORTED_TYPE', async () => { test('unknown typeflag is rejected as UNSUPPORTED_TYPE', async () => {
const {violations, approvedNames} = await validate(F1, 'f1.tar.gz') const {violations, approvedNames} = await validate(
unknownTypeflagArchive,
'unknown-typeflag.tar.gz'
)
expect(violations).toContain('UNSUPPORTED_TYPE') expect(violations).toContain('UNSUPPORTED_TYPE')
// The escaping entry must NOT be approved for extraction. // The escaping entry must NOT be approved for extraction.
expect(approvedNames).not.toContain('../../../../../../tmp/zip_slip_F1') expect(approvedNames).not.toContain(
'../../../../../../tmp/escaped_unknown_type'
)
}) })
test('F2: PAX path newline differential is rejected as PAX_DESYNC', async () => { test('PAX path newline differential is rejected as PAX_DESYNC', async () => {
const {violations, approvedNames} = await validate(F2, 'f2.tar.gz') const {violations, approvedNames} = await validate(
paxPathNewlineArchive,
'pax-path-newline.tar.gz'
)
expect(violations).toContain('PAX_DESYNC') expect(violations).toContain('PAX_DESYNC')
expect(approvedNames).toEqual([]) expect(approvedNames).toEqual([])
}) })
test('F2-linkpath: PAX linkpath newline differential is rejected as PAX_DESYNC', async () => { test('PAX linkpath newline differential is rejected as PAX_DESYNC', async () => {
const {violations} = await validate(F2L, 'f2l.tar.gz') const {violations} = await validate(
paxLinkpathNewlineArchive,
'pax-linkpath-newline.tar.gz'
)
expect(violations).toContain('PAX_DESYNC') expect(violations).toContain('PAX_DESYNC')
}) })
test('F3: oversized PAX header is rejected as UNSUPPORTED_TYPE', async () => { test('oversized PAX header is rejected as UNSUPPORTED_TYPE', async () => {
const {violations} = await validate(F3, 'f3.tar.gz') const {violations} = await validate(
oversizedPaxHeaderArchive,
'oversized-pax.tar.gz'
)
expect(violations).toContain('UNSUPPORTED_TYPE') expect(violations).toContain('UNSUPPORTED_TYPE')
}) })
test('F5: sparse typeflag is rejected as UNSUPPORTED_TYPE', async () => { test('sparse typeflag is rejected as UNSUPPORTED_TYPE', async () => {
const {violations, approvedNames} = await validate(F5, 'f5.tar.gz') const {violations, approvedNames} = await validate(
sparseTypeflagArchive,
'sparse-typeflag.tar.gz'
)
expect(violations).toContain('UNSUPPORTED_TYPE') expect(violations).toContain('UNSUPPORTED_TYPE')
expect(approvedNames).not.toContain('../../../../../../tmp/zip_slip_F5') expect(approvedNames).not.toContain('../../../../../../tmp/escaped_sparse')
}) })
test('glob metacharacter in entry path is rejected as GLOB_METACHAR', async () => { test('glob metacharacter in entry path is rejected as GLOB_METACHAR', async () => {
@@ -287,14 +310,14 @@ describe('listAndValidate: parser-differential bypass detection', () => {
expect(violations).toContain('UNSAFE_CHAR') expect(violations).toContain('UNSAFE_CHAR')
}) })
test('NUL byte in a symlink target (via PAX) is rejected as UNSAFE_CHAR', async () => { test('NUL byte in a symlink target (via PAX) is rejected as NUL_BYTE', async () => {
const archive = Buffer.concat([ const archive = Buffer.concat([
paxEntry(Buffer.from(paxRecord('linkpath=cache/sub/t\0'), 'ascii')), paxEntry(Buffer.from(paxRecord('linkpath=cache/sub/t\0'), 'ascii')),
header({name: 'cache/link', typeflag: '2', linkname: 'cache/sub/t'}), header({name: 'cache/link', typeflag: '2', linkname: 'cache/sub/t'}),
end() end()
]) ])
const {violations} = await validate(archive, 'nul-link.tar.gz') const {violations} = await validate(archive, 'nul-link.tar.gz')
expect(violations).toContain('UNSAFE_CHAR') expect(violations).toContain('NUL_BYTE')
}) })
test('legitimate long path via PAX: no violations, approved by its PAX path', async () => { test('legitimate long path via PAX: no violations, approved by its PAX path', async () => {
@@ -323,6 +346,28 @@ describe('listAndValidate: parser-differential bypass detection', () => {
expect(violations).toContain('PAX_UNKNOWN_KEY') expect(violations).toContain('PAX_UNKNOWN_KEY')
}) })
test('PAX sparse (GNU.sparse.name) is rejected as PAX_UNSUPPORTED_KEY', async () => {
// node-tar v7 ignores GNU sparse keys, so without this rejection the entry
// would be approved under its benign header path while system tar would
// reconstruct the file at GNU.sparse.name (here outside the cache roots).
const sparseName = '../../../../../../tmp/escaped_sparse_pax'
const body = Buffer.from(
paxRecord('GNU.sparse.major=1') +
paxRecord('GNU.sparse.minor=0') +
paxRecord(`GNU.sparse.name=${sparseName}`) +
paxRecord('GNU.sparse.realsize=4'),
'ascii'
)
const archive = Buffer.concat([
paxEntry(body),
fileEntry('cache/GNUSparseFile.0/decoy', 'data'),
end()
])
const {violations, approvedNames} = await validate(archive, 'sparse.tar.gz')
expect(violations).toContain('PAX_UNSUPPORTED_KEY')
expect(approvedNames).not.toContain(sparseName)
})
test('flood of extended headers is rejected (pending-meta cap)', async () => { test('flood of extended headers is rejected (pending-meta cap)', async () => {
const metas: Buffer[] = [] const metas: Buffer[] = []
for (let i = 0; i < 70; i++) { for (let i = 0; i < 70; i++) {
@@ -382,12 +427,12 @@ describeTar('extractTar end-to-end with system tar allow-list', () => {
expect(existsSync(path.join(dest, 'cache', 'sub', 'deep.txt'))).toBe(true) expect(existsSync(path.join(dest, 'cache', 'sub', 'deep.txt'))).toBe(true)
}) })
test('error mode, F2 archive: throws and writes nothing to the workspace', async () => { test('error mode, PAX path newline archive: throws and writes nothing to the workspace', async () => {
const dest = mkdtempSync(path.join(ROOT, 'extract-f2-')) const dest = mkdtempSync(path.join(ROOT, 'extract-pax-path-newline-'))
process.env['GITHUB_WORKSPACE'] = dest process.env['GITHUB_WORKSPACE'] = dest
const archivePath = path.join(dest, 'f2.tar.gz') const archivePath = path.join(dest, 'pax-path-newline.tar.gz')
mkdirSync(dest, {recursive: true}) mkdirSync(dest, {recursive: true})
writeFileSync(archivePath, gzipSync(F2)) writeFileSync(archivePath, gzipSync(paxPathNewlineArchive))
await expect( await expect(
extractTar(archivePath, CompressionMethod.Gzip, { extractTar(archivePath, CompressionMethod.Gzip, {
@@ -400,4 +445,55 @@ describeTar('extractTar end-to-end with system tar allow-list', () => {
expect(existsSync(path.join(dest, 'cache'))).toBe(false) expect(existsSync(path.join(dest, 'cache'))).toBe(false)
expect(existsSync(path.join(dest, 'safe.txt'))).toBe(false) expect(existsSync(path.join(dest, 'safe.txt'))).toBe(false)
}) })
test('error mode: a leading ./ entry still extracts (allow-list name matches)', async () => {
// node-tar surfaces the entry as `./cache/dotslash.txt`; the `-T` allow
// list must use the canonical `cache/dotslash.txt` so the member is not
// silently skipped. Verifies the canonicalMemberName normalization under
// whichever system tar is present (GNU on Linux CI, BSD on macOS).
const dest = mkdtempSync(path.join(ROOT, 'extract-dotslash-'))
process.env['GITHUB_WORKSPACE'] = dest
const archive = Buffer.concat([
dirEntry('cache/'),
fileEntry('./cache/dotslash.txt', 'dot'),
end()
])
const archivePath = path.join(dest, 'dotslash.tar.gz')
mkdirSync(dest, {recursive: true})
writeFileSync(archivePath, gzipSync(archive))
await extractTar(archivePath, CompressionMethod.Gzip, {
declaredPaths: ['cache/**'],
pathValidation: 'error'
})
expect(existsSync(path.join(dest, 'cache', 'dotslash.txt'))).toBe(true)
expect(readFileSync(path.join(dest, 'cache', 'dotslash.txt'), 'utf8')).toBe(
'dot'
)
})
test('error mode: a long path via PAX is extracted, not dropped by the allow-list', async () => {
// > 100 bytes, so the name travels via a PAX `path=` record (and a GNU
// long-name on creation). Exercises long-name matching in the `-T` list so
// a legitimate long path is not silently skipped during extraction.
const dest = mkdtempSync(path.join(ROOT, 'extract-long-'))
process.env['GITHUB_WORKSPACE'] = dest
const longRel = `cache/${'x'.repeat(110)}.txt`
const archive = Buffer.concat([
paxEntry(Buffer.from(paxRecord(`path=${longRel}`), 'ascii')),
fileEntry('cache/placeholder', 'L'),
end()
])
const archivePath = path.join(dest, 'long.tar.gz')
mkdirSync(dest, {recursive: true})
writeFileSync(archivePath, gzipSync(archive))
await extractTar(archivePath, CompressionMethod.Gzip, {
declaredPaths: ['cache/**'],
pathValidation: 'error'
})
expect(existsSync(path.join(dest, longRel))).toBe(true)
})
}) })
+38 -17
View File
@@ -97,6 +97,12 @@ and platform-specific behavior:
`C:/...`, Windows drive-relative `C:foo`, UNC `\\server\share`, UNC forward-slash, `C:/...`, Windows drive-relative `C:foo`, UNC `\\server\share`, UNC forward-slash,
UNC long-path prefix `\\?\C:\...` UNC long-path prefix `\\?\C:\...`
- **NUL byte attacks**: NUL in path, NUL in symlink target - **NUL byte attacks**: NUL in path, NUL in symlink target
- **Unsafe control characters**: embedded newline in an entry path or link
target → `UNSAFE_CHAR` (would corrupt a newline-delimited tar file list and
enables log injection). A tab is still accepted (legal, non-corrupting).
- **Glob metacharacters** (`* ? [ ]`) in an entry path → `GLOB_METACHAR`
(entry paths populate the system-tar `-T` allow-list, which bsdtar matches
with `fnmatch()`; link targets are exempt since they are not written there)
- **Symlink attacks**: - **Symlink attacks**:
- **Syntactic link-target rejects** (these fire before the containment check, - **Syntactic link-target rejects** (these fire before the containment check,
on the same allow-list as entry-path syntax): POSIX absolute (`/etc/passwd`), on the same allow-list as entry-path syntax): POSIX absolute (`/etc/passwd`),
@@ -149,11 +155,17 @@ cross-checks the result against `node-tar`'s view of the entry.
### `crossCheckMetaBodies` ### `crossCheckMetaBodies`
- Clean PAX `path` matching `node-tar` → no violations - Clean PAX `path` matching `node-tar` → no violations
- **F2 path desync** → `PAX_DESYNC` (node-tar resolved a safe name, the - **PAX path desync** → `PAX_DESYNC` (node-tar resolved a safe name, the
length-correct parse disagrees) length-correct parse disagrees)
- **F2-linkpath desync** → `PAX_DESYNC` - **PAX linkpath desync** → `PAX_DESYNC`
- Unknown PAX key → `PAX_UNKNOWN_KEY` - Unknown PAX key → `PAX_UNKNOWN_KEY`
- Known `SCHILY.` / `GNU.` / `LIBARCHIVE.` prefixed keys accepted - **`GNU.sparse.*` keys → `PAX_UNSUPPORTED_KEY`** — node-tar v7 ignores GNU
sparse keys, so it surfaces the entry under its header `path` while system
tar would reconstruct the file at `GNU.sparse.name` (an attacker-controlled,
potentially escaping location). A cache archive never legitimately contains
sparse members, so the namespace is rejected outright even though it falls
under the broadly-allowed `GNU.` prefix.
- Known `SCHILY.` / `LIBARCHIVE.` prefixed keys accepted
- GNU long-name raw body matching the entry path (or the link target) → no - GNU long-name raw body matching the entry path (or the link target) → no
violation; a body matching neither → flagged violation; a body matching neither → flagged
- No meta bodies → no violations - No meta bodies → no violations
@@ -164,23 +176,25 @@ cross-checks the result against `node-tar`'s view of the entry.
## Integration tests — parser-differential attacks (`tarPathValidationAttacks.test.ts`) ## Integration tests — parser-differential attacks (`tarPathValidationAttacks.test.ts`)
These build the F1 / F2 / F2-linkpath / F3 / F5 proof-of-concept archives from These build malicious archives as **raw tar bytes**, so they can craft PAX
the security analysis (see `docs/zip-slip-*`) as **raw tar bytes**, so they can bodies and typeflags that `node-tar`'s `Header` encoder would never emit. Each
craft malicious PAX bodies and typeflags that `node-tar`'s `Header` encoder is designed to make node-tar's in-process listing disagree with the path the
would never emit. They assert the validator refuses each one, and they include system `tar` extractor would write to. They assert the validator refuses each
real-`tar` end-to-end extraction assertions (using a scratch dir via one, and they include real-`tar` end-to-end extraction assertions (using a
`mkdtempSync`). scratch dir via `mkdtempSync`).
### Bypass detection (via `listAndValidate`) ### Bypass detection (via `listAndValidate`)
- **F1**: unknown typeflag → `UNSUPPORTED_TYPE` - Unknown typeflag → `UNSUPPORTED_TYPE`
- **F2**: PAX `path` newline differential → `PAX_DESYNC` - PAX `path` newline differential → `PAX_DESYNC`
- **F2-linkpath**: PAX `linkpath` newline differential → `PAX_DESYNC` - PAX `linkpath` newline differential → `PAX_DESYNC`
- **F3**: oversized PAX header → `UNSUPPORTED_TYPE` - Oversized PAX header → `UNSUPPORTED_TYPE`
- **F5**: sparse typeflag → `UNSUPPORTED_TYPE` - GNU sparse typeflag → `UNSUPPORTED_TYPE`
- **PAX sparse** (`GNU.sparse.name` with a regular typeflag) → `PAX_UNSUPPORTED_KEY`,
and the escaping sparse name is not approved
- Glob metacharacter in entry path → `GLOB_METACHAR` - Glob metacharacter in entry path → `GLOB_METACHAR`
- Newline in entry path → `UNSAFE_CHAR` - Newline in entry path → `UNSAFE_CHAR`
- NUL byte in a symlink target (delivered via PAX) → `UNSAFE_CHAR` - NUL byte in a symlink target (delivered via PAX) → `NUL_BYTE`
- Unknown PAX key → `PAX_UNKNOWN_KEY` - Unknown PAX key → `PAX_UNKNOWN_KEY`
- Flood of extended headers → rejected by the pending-meta cap - Flood of extended headers → rejected by the pending-meta cap
- Clean archive → `approvedNames` lists every concrete entry, no violations - Clean archive → `approvedNames` lists every concrete entry, no violations
@@ -188,9 +202,16 @@ real-`tar` end-to-end extraction assertions (using a scratch dir via
### End-to-end extraction (real `tar`) ### End-to-end extraction (real `tar`)
Run against whichever system `tar` is present, so CI exercises GNU tar on Linux
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, F2 archive → throws `CacheIntegrityError` and writes nothing - `'error'` mode, PAX path newline archive → throws `CacheIntegrityError` and
to the workspace writes nothing to the workspace
- `'error'` mode, leading `./` entry → still extracted (the `-T` allow-list
uses the canonical `cache/f` name, so the member is not silently skipped)
- `'error'` mode, long path (> 100 bytes) delivered via PAX → extracted, not
dropped by the allow-list (exercises long-name matching in `-T`)
## Integration tests — real archives (`listAndValidate.test.ts`) ## Integration tests — real archives (`listAndValidate.test.ts`)
+55 -97
View File
@@ -29,11 +29,15 @@ export interface ListAndValidateResult {
/** /**
* Upper bound on the size of a PAX / GNU extended-header body the validator is * Upper bound on the size of a PAX / GNU extended-header body the validator is
* willing to re-parse. node-tar's default is 1 MiB; real extended headers are * willing to re-parse, passed to node-tar as `maxMetaEntrySize`. Real extended
* a few hundred bytes at most. Anything larger is emitted as an * headers are a few hundred bytes at most; anything larger is emitted as an
* `ignoredEntry` (see the handler below) and recorded as a violation rather * `ignoredEntry` (see the handler below) and recorded as a violation rather
* than silently dropped — this is the F3 (oversized-PAX) defence, made * than silently dropped — the oversized-extended-header defence.
* explicit instead of relying on node-tar's internal default. *
* This deliberately matches node-tar's current 1 MiB default. Pinning it here
* (rather than leaving the option unset) keeps the oversized-header threshold
* under our control even if a future node-tar release changes its internal
* default.
*/ */
const META_REJECT_BYTES = 1024 * 1024 const META_REJECT_BYTES = 1024 * 1024
@@ -87,7 +91,9 @@ export async function listAndValidate(
// body string of each PAX / GNU long-name header *before* it emits the // body string of each PAX / GNU long-name header *before* it emits the
// concrete entry the header applies to. We re-parse these length-correctly // concrete entry the header applies to. We re-parse these length-correctly
// (see pax-reparse.ts) and cross-check against node-tar's resolved // (see pax-reparse.ts) and cross-check against node-tar's resolved
// path/linkpath to catch the F2 / F2-linkpath PAX newline differential. // path / linkpath to catch PAX newline differentials, where an embedded
// newline in a PAX value makes node-tar resolve a different path than the
// system tar that performs the extraction.
let pendingMeta: Buffer[] = [] let pendingMeta: Buffer[] = []
const consumePendingMeta = (): Buffer[] => { const consumePendingMeta = (): Buffer[] => {
@@ -96,6 +102,25 @@ export async function listAndValidate(
return bodies return bodies
} }
// Record a single validation failure. Every call site shares the same shape
// — path / linkpath / type come from the ReadEntry; only the code, reason and
// resolved location vary — so funnel them through one helper.
const addViolation = (
entry: ReadEntry,
code: PathValidationViolation['code'],
reason: string,
resolved: string
): void => {
violations.push({
path: entry.path,
linkpath: entry.linkpath || undefined,
resolved,
entryType: entry.type,
code,
reason
})
}
// For gzip we let node-tar handle decompression internally (its built-in // For gzip we let node-tar handle decompression internally (its built-in
// gzip support is mature). For zstd we spawn the system `zstd` binary so // gzip support is mature). For zstd we spawn the system `zstd` binary so
// we get the same `--long=30` window-size handling as the existing // we get the same `--long=30` window-size handling as the existing
@@ -109,8 +134,8 @@ export async function listAndValidate(
// via the captured error below. // via the captured error below.
strict: false, strict: false,
// Cap extended-header bodies explicitly so an oversized PAX header is // Cap extended-header bodies explicitly so an oversized PAX header is
// turned into a recorded `ignoredEntry` violation rather than relying on // turned into a recorded `ignoredEntry` violation and the oversized-header
// node-tar's internal default (F3 defence). // threshold stays under our control (see META_REJECT_BYTES).
maxMetaEntrySize: META_REJECT_BYTES, maxMetaEntrySize: META_REJECT_BYTES,
// Treat structural problems (bad archive, bad header, bad chksum) as // Treat structural problems (bad archive, bad header, bad chksum) as
// hard parse errors — silently ignoring them would let a corrupt // hard parse errors — silently ignoring them would let a corrupt
@@ -129,6 +154,7 @@ export async function listAndValidate(
onReadEntry: (entry: ReadEntry) => { onReadEntry: (entry: ReadEntry) => {
try { try {
const metaBodies = consumePendingMeta() const metaBodies = consumePendingMeta()
let approved = true
// Cross-check any PAX / long-name headers that preceded this entry // Cross-check any PAX / long-name headers that preceded this entry
// against node-tar's resolved view. A disagreement means node-tar // against node-tar's resolved view. A disagreement means node-tar
@@ -138,22 +164,16 @@ export async function listAndValidate(
entry.path, entry.path,
entry.linkpath || undefined entry.linkpath || undefined
)) { )) {
violations.push({ addViolation(entry, pax.code, pax.reason, entry.path)
path: entry.path, approved = false
linkpath: entry.linkpath || undefined,
resolved: entry.path,
entryType: entry.type,
code: pax.code,
reason: pax.reason
})
} }
// Reject characters that would corrupt the extraction allow-list or // validateEntry performs every per-string syntactic check (NUL and
// be reinterpreted by system tar's `-T` matching (glob metacharacters // other control characters, glob metacharacters, UNC / absolute /
// on bsdtar's fnmatch path). These have no legitimate use in a cache // drive-relative forms) on BOTH the entry path and any link target,
// entry path. // plus the allowed-root containment check. Keeping all of those checks
const charViolation = checkUnsafeChars(entry.path, entry.linkpath) // in one place (pathValidation) is what makes entry paths and link
// targets treated consistently.
const result = validateEntry( const result = validateEntry(
entry.path, entry.path,
entry.linkpath || undefined, entry.linkpath || undefined,
@@ -162,32 +182,16 @@ export async function listAndValidate(
extractCwd extractCwd
) )
if (!result.ok) { if (!result.ok) {
violations.push({ addViolation(entry, result.code, result.reason, result.resolved)
path: entry.path, approved = false
linkpath: entry.linkpath || undefined,
resolved: result.resolved,
entryType: entry.type,
code: result.code,
reason: result.reason
})
} }
if (charViolation) { // Only entries that passed every check (PAX cross-check and path
violations.push({ // validation) are eligible for the extraction allow-list. When any
path: entry.path, // violation exists the allow-list is unused anyway — extraction is
linkpath: entry.linkpath || undefined, // either blocked in 'error' mode or runs unrestricted in 'warn' mode —
resolved: entry.path, // but tracking approval per entry keeps the contract simple.
entryType: entry.type, if (approved) {
code: charViolation.code,
reason: charViolation.reason
})
}
// Only entries that passed every check are eligible for the
// extraction allow-list. (When any violation exists the allow-list is
// unused — extraction is either blocked in 'error' mode or runs
// unrestricted in 'warn' mode — so this is belt-and-braces.)
if (result.ok && !charViolation) {
approvedNames.push(entry.path) approvedNames.push(entry.path)
} }
} finally { } finally {
@@ -207,14 +211,12 @@ export async function listAndValidate(
// The meta header(s) that preceded an ignored entry belong to it; discard // The meta header(s) that preceded an ignored entry belong to it; discard
// them so they aren't mis-associated with a later concrete entry. // them so they aren't mis-associated with a later concrete entry.
consumePendingMeta() consumePendingMeta()
violations.push({ addViolation(
path: entry.path, entry,
linkpath: entry.linkpath || undefined, 'UNSUPPORTED_TYPE',
resolved: entry.path, `parser ignored entry of type ${entry.type}`,
entryType: entry.type, entry.path
code: 'UNSUPPORTED_TYPE', )
reason: `parser ignored entry of type ${entry.type}`
})
}) })
// Capture the raw body of each extended-header (meta) entry. node-tar // Capture the raw body of each extended-header (meta) entry. node-tar
@@ -239,50 +241,6 @@ export async function listAndValidate(
return {violations, approvedNames} return {violations, approvedNames}
} }
/**
* Reject characters in an entry path (or link target) that have no legitimate
* place in a cache archive and that would either corrupt the extraction
* allow-list or be reinterpreted by system tar's `-T` member matching:
*
* - NUL / newline in the entry path — would split or terminate a list entry.
* - glob metacharacters (`* ? [ ]`) in the entry path — bsdtar matches `-T`
* names with `fnmatch()`, so an unescaped metacharacter could match (and
* extract) members other than the one approved. GNU tar's `--no-wildcards`
* also covers this, but rejecting unconditionally keeps the behaviour
* identical across tar implementations.
* - NUL in a link target — same list-corruption concern.
*/
function checkUnsafeChars(
entryPath: string,
linkPath: string | undefined
): {code: 'UNSAFE_CHAR' | 'GLOB_METACHAR'; reason: string} | undefined {
if (entryPath.includes('\0') || entryPath.includes('\n')) {
return {
code: 'UNSAFE_CHAR',
reason: `entry path contains an unsafe control character: ${JSON.stringify(
entryPath
)}`
}
}
if (/[*?[\]]/.test(entryPath)) {
return {
code: 'GLOB_METACHAR',
reason: `entry path contains a glob metacharacter: ${JSON.stringify(
entryPath
)}`
}
}
if (linkPath !== undefined && linkPath.includes('\0')) {
return {
code: 'UNSAFE_CHAR',
reason: `link target contains an unsafe control character: ${JSON.stringify(
linkPath
)}`
}
}
return undefined
}
async function streamArchiveTo( async function streamArchiveTo(
archivePath: string, archivePath: string,
compressionMethod: CompressionMethod, compressionMethod: CompressionMethod,
+33 -1
View File
@@ -30,6 +30,7 @@ export interface PathValidationViolation {
| 'PAX_DESYNC' | 'PAX_DESYNC'
| 'PAX_PARSE_FAIL' | 'PAX_PARSE_FAIL'
| 'PAX_UNKNOWN_KEY' | 'PAX_UNKNOWN_KEY'
| 'PAX_UNSUPPORTED_KEY'
| 'UNSAFE_CHAR' | 'UNSAFE_CHAR'
| 'GLOB_METACHAR' | 'GLOB_METACHAR'
/** Human-readable description of the violation. */ /** Human-readable description of the violation. */
@@ -400,7 +401,12 @@ export function validateEntry(
* pre-resolution syntactic checks. * pre-resolution syntactic checks.
*/ */
interface PathSyntaxError { interface PathSyntaxError {
code: 'NUL_BYTE' | 'UNC_PATH' | 'ABSOLUTE_PATH' code:
| 'NUL_BYTE'
| 'UNSAFE_CHAR'
| 'UNC_PATH'
| 'ABSOLUTE_PATH'
| 'GLOB_METACHAR'
reason: string reason: string
} }
@@ -429,6 +435,19 @@ function checkPathSyntax(
if (p.includes('\0')) { if (p.includes('\0')) {
return {code: 'NUL_BYTE', reason: `NUL byte in ${kind}`} return {code: 'NUL_BYTE', reason: `NUL byte in ${kind}`}
} }
// Reject newline characters. They have no legitimate place in a cache entry
// path or link target, would corrupt a newline-delimited tar file list, and
// enable log / line injection in the violation output. NUL is handled above
// with its own, more specific code. Applied to both entry paths and link
// targets so the two are treated consistently.
if (p.includes('\n')) {
return {
code: 'UNSAFE_CHAR',
reason: `unsafe control character (newline) in ${kind}: ${JSON.stringify(
p
)}`
}
}
// Reject UNC paths. Check the original string before any separator // Reject UNC paths. Check the original string before any separator
// normalization because UNC is identified by leading `\\` or `//`. // normalization because UNC is identified by leading `\\` or `//`.
if ( if (
@@ -454,6 +473,19 @@ function checkPathSyntax(
reason: `absolute ${kind} not allowed: ${p}` reason: `absolute ${kind} not allowed: ${p}`
} }
} }
// Reject glob metacharacters, but only in entry paths. Approved entry paths
// are written to the system-tar `-T` extraction allow-list, and bsdtar
// matches those names with fnmatch(), so an unescaped `*`, `?`, `[` or `]`
// could select (and extract) members other than the one approved. GNU tar
// is additionally run with --no-wildcards, but rejecting here keeps the
// behavior identical across tar implementations. Link targets are not
// written to the allow-list, so they are exempt.
if (kind === 'entry path' && /[*?[\]]/.test(p)) {
return {
code: 'GLOB_METACHAR',
reason: `glob metacharacter in ${kind}: ${JSON.stringify(p)}`
}
}
return undefined return undefined
} }
+30 -4
View File
@@ -13,8 +13,7 @@
* legally contain `\n`. GNU tar and libarchive (bsdtar / Windows `tar.exe`) * legally contain `\n`. GNU tar and libarchive (bsdtar / Windows `tar.exe`)
* both parse by consuming exactly `<decimal-length>` bytes, so a value * both parse by consuming exactly `<decimal-length>` bytes, so a value
* containing an embedded `"\n<len> path=<other>\n"` desynchronises node-tar * containing an embedded `"\n<len> path=<other>\n"` desynchronises node-tar
* from every real extractor the F2 / F2-linkpath parser-differential * from every real extractor a path / linkpath parser-differential bypass.
* bypasses.
* *
* This module re-parses each captured PAX body the way every real extractor * This module re-parses each captured PAX body the way every real extractor
* does (length-prefixed, byte-accurate) and cross-checks the result against * does (length-prefixed, byte-accurate) and cross-checks the result against
@@ -29,7 +28,11 @@
*/ */
/** Machine-readable reason codes produced by the PAX cross-check. */ /** Machine-readable reason codes produced by the PAX cross-check. */
export type PaxReparseCode = 'PAX_DESYNC' | 'PAX_PARSE_FAIL' | 'PAX_UNKNOWN_KEY' export type PaxReparseCode =
| 'PAX_DESYNC'
| 'PAX_PARSE_FAIL'
| 'PAX_UNKNOWN_KEY'
| 'PAX_UNSUPPORTED_KEY'
/** A single disagreement surfaced by {@link crossCheckMetaBodies}. */ /** A single disagreement surfaced by {@link crossCheckMetaBodies}. */
export interface PaxReparseViolation { export interface PaxReparseViolation {
@@ -87,6 +90,24 @@ function isKnownPaxKey(key: string): boolean {
return PAX_KNOWN_PREFIXES.some(prefix => key.startsWith(prefix)) return PAX_KNOWN_PREFIXES.some(prefix => key.startsWith(prefix))
} }
/**
* PAX key prefixes that system tar (GNU tar / libarchive) acts on to place or
* reconstruct file content but that node-tar v7 does NOT process. node-tar
* surfaces such an entry under its header `path`, while system tar would write
* it elsewhere GNU sparse files are reconstructed at `GNU.sparse.name`, which
* an attacker can point outside the cache roots. That is a listing-vs-extraction
* parser differential the `path` / `linkpath` cross-check cannot see (the key is
* neither `path` nor `linkpath`). A cache archive never legitimately contains
* sparse members, so any key in this namespace is rejected outright. This is
* checked before {@link isKnownPaxKey} so it takes precedence over the broad
* `GNU.` allow-list prefix.
*/
const PAX_REJECTED_PREFIXES: readonly string[] = ['GNU.sparse.']
function isRejectedPaxKey(key: string): boolean {
return PAX_REJECTED_PREFIXES.some(prefix => key.startsWith(prefix))
}
/** Result of a length-correct parse of a single PAX extended-header body. */ /** Result of a length-correct parse of a single PAX extended-header body. */
export interface PaxParseResult { export interface PaxParseResult {
/** Decoded records, last-write-wins per key (POSIX-correct). Values kept as /** Decoded records, last-write-wins per key (POSIX-correct). Values kept as
@@ -200,7 +221,12 @@ export function crossCheckMetaBodies(
// A well-formed PAX extended header. // A well-formed PAX extended header.
sawPax = true sawPax = true
for (const key of keys) { for (const key of keys) {
if (!isKnownPaxKey(key)) { if (isRejectedPaxKey(key)) {
violations.push({
code: 'PAX_UNSUPPORTED_KEY',
reason: `unsupported placement-affecting PAX key '${key}' in extended header (node-tar ignores it; system tar would act on it)`
})
} else if (!isKnownPaxKey(key)) {
violations.push({ violations.push({
code: 'PAX_UNKNOWN_KEY', code: 'PAX_UNKNOWN_KEY',
reason: `unknown PAX key '${key}' in extended header` reason: `unknown PAX key '${key}' in extended header`
+26 -8
View File
@@ -20,7 +20,8 @@ import {
PathValidationMode, PathValidationMode,
PathValidationViolation, PathValidationViolation,
deriveAllowedRoots, deriveAllowedRoots,
formatViolationSummary formatViolationSummary,
getWorkingDirectory
} from './pathValidation.js' } from './pathValidation.js'
const IS_WINDOWS = process.platform === 'win32' const IS_WINDOWS = process.platform === 'win32'
@@ -203,10 +204,6 @@ async function getCommands(
return [args.join(' ')] return [args.join(' ')]
} }
function getWorkingDirectory(): string {
return process.env['GITHUB_WORKSPACE'] ?? process.cwd()
}
// Common function for extractTar and listTar to get the compression method // Common function for extractTar and listTar to get the compression method
async function getDecompressionProgram( async function getDecompressionProgram(
tarPath: ArchiveTool, tarPath: ArchiveTool,
@@ -431,16 +428,37 @@ function writeAllowList(approvedNames: string[]): string {
const allowListPath = path.join( const allowListPath = path.join(
os.tmpdir(), os.tmpdir(),
`cache-allow-${process.pid}-${Date.now()}-${crypto `cache-allow-${process.pid}-${Date.now()}-${crypto
.randomBytes(4) .randomBytes(8)
.toString('hex')}.lst` .toString('hex')}.lst`
) )
const payload = Buffer.concat( const payload = Buffer.concat(
approvedNames.flatMap(name => [Buffer.from(name, 'utf8'), Buffer.from([0])]) approvedNames.flatMap(name => [
Buffer.from(canonicalMemberName(name), 'utf8'),
Buffer.from([0])
])
) )
writeFileSync(allowListPath, payload, {mode: 0o600}) // `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
// path on a shared/self-hosted runner cannot redirect or capture the write.
// mode 0o600 keeps the list readable only by the current user. Both options
// behave consistently on Windows, macOS and Linux.
writeFileSync(allowListPath, payload, {mode: 0o600, flag: 'wx'})
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