mirror of
https://github.com/actions/toolkit.git
synced 2026-08-07 00:00:18 +02:00
feat: optional path validation during cache restore
This commit is contained in:
+387
@@ -0,0 +1,387 @@
|
||||
import {mkdirSync, mkdtempSync, writeFileSync, rmSync} from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as zlib from 'zlib'
|
||||
import {execSync} from 'child_process'
|
||||
import {Header} from 'tar'
|
||||
import {CompressionMethod} from '../src/internal/constants'
|
||||
import {listAndValidate} from '../src/internal/listAndValidate'
|
||||
|
||||
/**
|
||||
* Real-archive integration tests for listAndValidate. These build small tar
|
||||
* archives in-memory using `tar.Header`, write them to disk, and run them
|
||||
* through the same parser the production code uses. No mocks.
|
||||
*/
|
||||
|
||||
interface TestEntry {
|
||||
path: string
|
||||
type: 'File' | 'Directory' | 'SymbolicLink' | 'Link' | 'CharacterDevice'
|
||||
linkpath?: string
|
||||
body?: Buffer
|
||||
}
|
||||
|
||||
function buildTarArchive(entries: TestEntry[]): Buffer {
|
||||
const blocks: Buffer[] = []
|
||||
for (const entry of entries) {
|
||||
const body = entry.body ?? Buffer.alloc(0)
|
||||
const header = new Header({
|
||||
path: entry.path,
|
||||
mode: 0o644,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
size: body.length,
|
||||
mtime: new Date(0),
|
||||
type: entry.type,
|
||||
linkpath: entry.linkpath,
|
||||
uname: 'root',
|
||||
gname: 'root'
|
||||
})
|
||||
const headerBuf = Buffer.alloc(512)
|
||||
header.encode(headerBuf, 0)
|
||||
blocks.push(headerBuf)
|
||||
if (body.length > 0) {
|
||||
blocks.push(body)
|
||||
const pad = (512 - (body.length % 512)) % 512
|
||||
if (pad > 0) blocks.push(Buffer.alloc(pad))
|
||||
}
|
||||
}
|
||||
// Two zero blocks mark end of archive.
|
||||
blocks.push(Buffer.alloc(1024))
|
||||
return Buffer.concat(blocks)
|
||||
}
|
||||
|
||||
const TEST_ROOT = mkdtempSync(path.join(os.tmpdir(), 'cache-listAndValidate-'))
|
||||
|
||||
/**
|
||||
* Detect whether the `zstd` binary is on PATH at module load. We use a
|
||||
* conditional `describe.skip` (rather than per-test early-returns) so that
|
||||
* Jest reports skipped tests as `skipped` in its summary — silently
|
||||
* `return`-ing from a test reports it as `passed`, which masks coverage gaps
|
||||
* on machines where zstd is missing.
|
||||
*/
|
||||
const ZSTD_AVAILABLE = ((): boolean => {
|
||||
try {
|
||||
execSync(process.platform === 'win32' ? 'where zstd' : 'which zstd', {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})()
|
||||
const describeZstd = ZSTD_AVAILABLE ? describe : describe.skip
|
||||
|
||||
function workspace(): string {
|
||||
return path.join(TEST_ROOT, 'workspace')
|
||||
}
|
||||
|
||||
function writeArchive(name: string, data: Buffer): string {
|
||||
mkdirSync(TEST_ROOT, {recursive: true})
|
||||
const fullPath = path.join(TEST_ROOT, name)
|
||||
writeFileSync(fullPath, data)
|
||||
return fullPath
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
mkdirSync(workspace(), {recursive: true})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
try {
|
||||
rmSync(TEST_ROOT, {recursive: true, force: true})
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
})
|
||||
|
||||
describe('listAndValidate (real archives)', () => {
|
||||
describe('uncompressed tar', () => {
|
||||
test('clean archive: zero violations', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/file1.txt', type: 'File', body: Buffer.from('hello')},
|
||||
{path: 'cache/sub/file2.txt', type: 'File', body: Buffer.from('world')},
|
||||
{path: 'cache/sub/', type: 'Directory'}
|
||||
])
|
||||
const archivePath = writeArchive('clean.tar', archive)
|
||||
// Inject a fake gzip header by re-compressing? No — we want to test
|
||||
// the uncompressed path. listAndValidate doesn't have a "raw" code
|
||||
// path; it always assumes compression based on `compressionMethod`.
|
||||
// To test uncompressed bytes we run it through gzip and pass Gzip.
|
||||
const gzipped = zlib.gzipSync(archive)
|
||||
writeFileSync(archivePath, gzipped)
|
||||
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('gzip-compressed tar', () => {
|
||||
test('clean archive: zero violations', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/file.txt', type: 'File', body: Buffer.from('hi')}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'clean.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
test('classic ../../../etc/passwd traversal: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/legit.txt', type: 'File', body: Buffer.from('ok')},
|
||||
{
|
||||
path: '../../../etc/passwd',
|
||||
type: 'File',
|
||||
body: Buffer.from('pwned')
|
||||
}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'traversal.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].path).toBe('../../../etc/passwd')
|
||||
expect(violations[0].entryType).toBe('File')
|
||||
})
|
||||
|
||||
test('absolute path entry: one violation', async () => {
|
||||
const absPath =
|
||||
process.platform === 'win32'
|
||||
? 'C:/Windows/System32/evil.dll'
|
||||
: '/etc/cron.d/evil'
|
||||
const archive = buildTarArchive([
|
||||
{path: absPath, type: 'File', body: Buffer.from('x')}
|
||||
])
|
||||
const archivePath = writeArchive('abs.tar.gz', zlib.gzipSync(archive))
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].path).toBe(absPath)
|
||||
})
|
||||
|
||||
test('symlink with absolute target: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{
|
||||
path: 'cache/link',
|
||||
type: 'SymbolicLink',
|
||||
linkpath: '/etc/passwd'
|
||||
}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'symlink-abs.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].entryType).toBe('SymbolicLink')
|
||||
expect(violations[0].linkpath).toBe('/etc/passwd')
|
||||
})
|
||||
|
||||
test('symlink target traversing out of allowed roots: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{
|
||||
path: 'cache/link',
|
||||
type: 'SymbolicLink',
|
||||
linkpath: '../../../etc/passwd'
|
||||
}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'symlink-traverse.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('hardlink to ../etc/passwd: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{
|
||||
path: 'cache/link',
|
||||
type: 'Link',
|
||||
linkpath: '../../../etc/passwd'
|
||||
}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'hardlink-traverse.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].entryType).toBe('Link')
|
||||
})
|
||||
|
||||
test('mixed clean and malicious entries: only the bad ones are reported', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/a.txt', type: 'File', body: Buffer.from('1')},
|
||||
{path: '../escape.txt', type: 'File', body: Buffer.from('2')},
|
||||
{path: 'cache/sub/b.txt', type: 'File', body: Buffer.from('3')},
|
||||
{
|
||||
path: 'cache/link',
|
||||
type: 'SymbolicLink',
|
||||
linkpath: '/tmp/x'
|
||||
},
|
||||
{path: 'cache/sub/c.txt', type: 'File', body: Buffer.from('4')}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'mixed.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
const paths = violations.map(v => v.path).sort()
|
||||
expect(paths).toEqual(['../escape.txt', 'cache/link'])
|
||||
})
|
||||
|
||||
test('character device entry is rejected', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/dev', type: 'CharacterDevice'}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'chardev.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0].entryType).toBe('CharacterDevice')
|
||||
})
|
||||
|
||||
test('archive with a single small entry: zero violations', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/tiny.txt', type: 'File', body: Buffer.from('1')}
|
||||
])
|
||||
const archivePath = writeArchive(
|
||||
'single.tar.gz',
|
||||
zlib.gzipSync(archive)
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
test('corrupted / non-tar bytes: throws Error', async () => {
|
||||
const archivePath = writeArchive(
|
||||
'corrupt.tar.gz',
|
||||
zlib.gzipSync(Buffer.from('this is not a tar archive at all'))
|
||||
)
|
||||
await expect(
|
||||
listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Gzip,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describeZstd('zstd-compressed tar', () => {
|
||||
test('clean archive (Zstd with --long): zero violations', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/x.bin', type: 'File', body: Buffer.from('hello')}
|
||||
])
|
||||
// Compress via the zstd binary with --long=30 to mirror the real
|
||||
// cache-creation pipeline.
|
||||
const archivePath = path.join(TEST_ROOT, 'clean.tar.zst')
|
||||
mkdirSync(TEST_ROOT, {recursive: true})
|
||||
writeFileSync(`${archivePath}.raw`, archive)
|
||||
execSync(
|
||||
`zstd --long=30 --force -o "${archivePath}" "${archivePath}.raw"`,
|
||||
{stdio: 'ignore'}
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Zstd,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
test('traversal in zstd archive: one violation', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: '../escape.txt', type: 'File', body: Buffer.from('x')}
|
||||
])
|
||||
const archivePath = path.join(TEST_ROOT, 'evil.tar.zst')
|
||||
writeFileSync(`${archivePath}.raw`, archive)
|
||||
execSync(
|
||||
`zstd --long=30 --force -o "${archivePath}" "${archivePath}.raw"`,
|
||||
{stdio: 'ignore'}
|
||||
)
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.Zstd,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('ZstdWithoutLong compression method works', async () => {
|
||||
const archive = buildTarArchive([
|
||||
{path: 'cache/y.bin', type: 'File', body: Buffer.from('z')}
|
||||
])
|
||||
const archivePath = path.join(TEST_ROOT, 'clean.short.tar.zst')
|
||||
writeFileSync(`${archivePath}.raw`, archive)
|
||||
execSync(`zstd --force -o "${archivePath}" "${archivePath}.raw"`, {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
const violations = await listAndValidate(
|
||||
archivePath,
|
||||
CompressionMethod.ZstdWithoutLong,
|
||||
[path.join(workspace(), 'cache')],
|
||||
workspace()
|
||||
)
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
+5
-2
@@ -11,6 +11,7 @@ const downloadConcurrency = 8
|
||||
const timeoutInMs = 30000
|
||||
const segmentTimeoutInMs = 600000
|
||||
const lookupOnly = false
|
||||
const pathValidation = 'off'
|
||||
|
||||
test('getDownloadOptions sets defaults', async () => {
|
||||
const actualOptions = getDownloadOptions()
|
||||
@@ -21,7 +22,8 @@ test('getDownloadOptions sets defaults', async () => {
|
||||
downloadConcurrency,
|
||||
timeoutInMs,
|
||||
segmentTimeoutInMs,
|
||||
lookupOnly
|
||||
lookupOnly,
|
||||
pathValidation
|
||||
})
|
||||
})
|
||||
|
||||
@@ -32,7 +34,8 @@ test('getDownloadOptions overrides all settings', async () => {
|
||||
downloadConcurrency: 14,
|
||||
timeoutInMs: 20000,
|
||||
segmentTimeoutInMs: 3600000,
|
||||
lookupOnly: true
|
||||
lookupOnly: true,
|
||||
pathValidation: 'error'
|
||||
}
|
||||
|
||||
const actualOptions = getDownloadOptions(expectedOptions)
|
||||
|
||||
+963
@@ -0,0 +1,963 @@
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
PathValidationViolation,
|
||||
deriveAllowedRoots,
|
||||
formatViolationSummary,
|
||||
validateEntry
|
||||
} from '../src/internal/pathValidation'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin'
|
||||
|
||||
describe('deriveAllowedRoots', () => {
|
||||
const cwd = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
|
||||
describe('glob-prefix stripping', () => {
|
||||
test('literal absolute path is returned unchanged', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('star suffix strips trailing glob segment', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\bar\\*' : '/foo/bar/*'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('question-mark glob strips its segment', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\bar\\?c' : '/foo/bar/?c'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('character class strips its segment', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\[bc]\\d' : '/foo/[bc]/d'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('brace expansion strips its segment', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\{x,y}\\d' : '/foo/{x,y}/d'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('** in the middle strips at the **', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\**\\d' : '/foo/**/d'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('leading ** falls back to extraction CWD', () => {
|
||||
const expected = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
expect(deriveAllowedRoots(['**/node_modules'], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('single * falls back to extraction CWD', () => {
|
||||
const expected = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
expect(deriveAllowedRoots(['*'], cwd)).toEqual([expected])
|
||||
})
|
||||
})
|
||||
|
||||
describe('negation handling', () => {
|
||||
test('negation pattern (! prefix) is dropped from allowed roots', () => {
|
||||
const input = IS_WINDOWS
|
||||
? ['!C:\\foo\\secret']
|
||||
: ['!/foo/secret']
|
||||
expect(deriveAllowedRoots(input, cwd)).toEqual([])
|
||||
})
|
||||
|
||||
test('negation does not subtract from a sibling allowed root', () => {
|
||||
const allowed = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
const negated = IS_WINDOWS ? '!C:\\foo\\secret' : '!/foo/secret'
|
||||
expect(deriveAllowedRoots([allowed, negated], cwd)).toEqual([allowed])
|
||||
})
|
||||
})
|
||||
|
||||
describe('path expansion', () => {
|
||||
test('~ expands to home directory', () => {
|
||||
expect(deriveAllowedRoots(['~'], cwd)).toEqual([os.homedir()])
|
||||
})
|
||||
|
||||
test('~/x expands to home/x', () => {
|
||||
const expected = path.join(os.homedir(), '.cache')
|
||||
expect(deriveAllowedRoots(['~/.cache'], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('${VAR} expands an environment variable', () => {
|
||||
const original = process.env['CACHE_TEST_ROOT']
|
||||
process.env['CACHE_TEST_ROOT'] = IS_WINDOWS ? 'C:\\envroot' : '/envroot'
|
||||
try {
|
||||
const expected = IS_WINDOWS ? 'C:\\envroot\\sub' : '/envroot/sub'
|
||||
expect(
|
||||
deriveAllowedRoots(['${CACHE_TEST_ROOT}/sub'], cwd)
|
||||
).toEqual([expected])
|
||||
} finally {
|
||||
if (original === undefined) delete process.env['CACHE_TEST_ROOT']
|
||||
else process.env['CACHE_TEST_ROOT'] = original
|
||||
}
|
||||
})
|
||||
|
||||
test('$VAR style expands an environment variable', () => {
|
||||
const original = process.env['CACHE_TEST_ROOT']
|
||||
process.env['CACHE_TEST_ROOT'] = IS_WINDOWS ? 'C:\\envroot' : '/envroot'
|
||||
try {
|
||||
const expected = IS_WINDOWS ? 'C:\\envroot\\sub' : '/envroot/sub'
|
||||
expect(deriveAllowedRoots(['$CACHE_TEST_ROOT/sub'], cwd)).toEqual([
|
||||
expected
|
||||
])
|
||||
} finally {
|
||||
if (original === undefined) delete process.env['CACHE_TEST_ROOT']
|
||||
else process.env['CACHE_TEST_ROOT'] = original
|
||||
}
|
||||
})
|
||||
|
||||
test('%VAR% Windows-style expands an environment variable', () => {
|
||||
const original = process.env['CACHE_TEST_WIN_ROOT']
|
||||
process.env['CACHE_TEST_WIN_ROOT'] = IS_WINDOWS ? 'C:\\winroot' : '/winroot'
|
||||
try {
|
||||
const expected = IS_WINDOWS ? 'C:\\winroot\\sub' : '/winroot/sub'
|
||||
expect(
|
||||
deriveAllowedRoots(['%CACHE_TEST_WIN_ROOT%/sub'], cwd)
|
||||
).toEqual([expected])
|
||||
} finally {
|
||||
if (original === undefined) delete process.env['CACHE_TEST_WIN_ROOT']
|
||||
else process.env['CACHE_TEST_WIN_ROOT'] = original
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown env var expands to empty string', () => {
|
||||
delete process.env['DEFINITELY_NOT_SET_VAR_XYZ123']
|
||||
// After expansion: "/sub", which is absolute on POSIX, so it stays /sub.
|
||||
// On Windows it becomes a relative path resolved against cwd.
|
||||
const result = deriveAllowedRoots(
|
||||
['${DEFINITELY_NOT_SET_VAR_XYZ123}/sub'],
|
||||
cwd
|
||||
)
|
||||
expect(result).toHaveLength(1)
|
||||
// Just ensure no crash and produces a deterministic value.
|
||||
expect(typeof result[0]).toBe('string')
|
||||
})
|
||||
|
||||
test('env value containing glob characters is preserved verbatim, not truncated', () => {
|
||||
// Regression test: an earlier implementation expanded env vars before
|
||||
// detecting glob characters, so an env value that happened to contain
|
||||
// a `*` or `{` would truncate the prefix mid-path and silently broaden
|
||||
// the allowed root. Glob detection must run on the pre-expansion text.
|
||||
const original = process.env['CACHE_TEST_ROOT']
|
||||
process.env['CACHE_TEST_ROOT'] = IS_WINDOWS
|
||||
? 'C:\\envroot*odd'
|
||||
: '/envroot*odd'
|
||||
try {
|
||||
const expected = IS_WINDOWS
|
||||
? 'C:\\envroot*odd\\sub'
|
||||
: '/envroot*odd/sub'
|
||||
expect(
|
||||
deriveAllowedRoots(['${CACHE_TEST_ROOT}/sub'], cwd)
|
||||
).toEqual([expected])
|
||||
} finally {
|
||||
if (original === undefined) delete process.env['CACHE_TEST_ROOT']
|
||||
else process.env['CACHE_TEST_ROOT'] = original
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalization', () => {
|
||||
test('trailing slash is normalized away', () => {
|
||||
const a = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
const b = IS_WINDOWS ? 'C:\\foo\\bar\\' : '/foo/bar/'
|
||||
expect(deriveAllowedRoots([a], cwd)).toEqual(deriveAllowedRoots([b], cwd))
|
||||
})
|
||||
|
||||
test('relative paths resolve against CWD', () => {
|
||||
const expected = IS_WINDOWS
|
||||
? 'C:\\workspace\\node_modules'
|
||||
: '/workspace/node_modules'
|
||||
expect(deriveAllowedRoots(['node_modules'], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('. resolves to CWD', () => {
|
||||
const expected = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
expect(deriveAllowedRoots(['.'], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('whitespace-only path is dropped', () => {
|
||||
expect(deriveAllowedRoots([' '], cwd)).toEqual([])
|
||||
})
|
||||
|
||||
test('empty string path is dropped', () => {
|
||||
expect(deriveAllowedRoots([''], cwd)).toEqual([])
|
||||
})
|
||||
|
||||
test('input with embedded ./ segments normalizes', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\.\\bar' : '/foo/./bar'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
|
||||
test('input with embedded ../ segments normalizes', () => {
|
||||
const input = IS_WINDOWS ? 'C:\\foo\\bar\\..\\baz' : '/foo/bar/../baz'
|
||||
const expected = IS_WINDOWS ? 'C:\\foo\\baz' : '/foo/baz'
|
||||
expect(deriveAllowedRoots([input], cwd)).toEqual([expected])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deduplication and subsumption', () => {
|
||||
test('identical roots are deduplicated', () => {
|
||||
const a = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
expect(deriveAllowedRoots([a, a], cwd)).toEqual([a])
|
||||
})
|
||||
|
||||
test('child root is subsumed by parent', () => {
|
||||
const parent = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
const child = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([parent, child], cwd)).toEqual([parent])
|
||||
})
|
||||
|
||||
test('child first then parent still results in just parent', () => {
|
||||
const parent = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
const child = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
expect(deriveAllowedRoots([child, parent], cwd)).toEqual([parent])
|
||||
})
|
||||
|
||||
test('sibling prefix collision does NOT subsume', () => {
|
||||
// /aa is NOT a child of /a — must be kept as a separate root.
|
||||
const a = IS_WINDOWS ? 'C:\\a' : '/a'
|
||||
const aa = IS_WINDOWS ? 'C:\\aa' : '/aa'
|
||||
const result = deriveAllowedRoots([a, aa], cwd)
|
||||
expect(result).toContain(a)
|
||||
expect(result).toContain(aa)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('completely disjoint roots are both kept', () => {
|
||||
const a = IS_WINDOWS ? 'C:\\foo' : '/foo'
|
||||
const b = IS_WINDOWS ? 'C:\\bar' : '/bar'
|
||||
const result = deriveAllowedRoots([a, b], cwd)
|
||||
expect(result).toContain(a)
|
||||
expect(result).toContain(b)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
if (CASE_INSENSITIVE) {
|
||||
test('case-insensitive FS: roots that differ only in case dedupe', () => {
|
||||
const lower = IS_WINDOWS ? 'C:\\foo\\bar' : '/foo/bar'
|
||||
const upper = IS_WINDOWS ? 'C:\\FOO\\bar' : '/FOO/bar'
|
||||
const result = deriveAllowedRoots([lower, upper], cwd)
|
||||
// Either form may win, but only one root should survive.
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('validateEntry', () => {
|
||||
const cwd = IS_WINDOWS ? 'C:\\workspace' : '/workspace'
|
||||
const allowedRoots = [
|
||||
IS_WINDOWS ? 'C:\\workspace\\node_modules' : '/workspace/node_modules',
|
||||
IS_WINDOWS ? 'C:\\workspace\\.cache' : '/workspace/.cache'
|
||||
]
|
||||
|
||||
describe('legitimate entries (must pass)', () => {
|
||||
test('regular file under root', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('deeply nested file', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/' + Array(50).fill('sub').join('/') + '/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('entry with leading ./', () => {
|
||||
const r = validateEntry(
|
||||
'./node_modules/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('entry with non-escaping .. segment', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/sub/../foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('entry with double slash normalizes ok', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules//foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('directory entry (trailing slash)', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/',
|
||||
undefined,
|
||||
'Directory',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('filename with spaces', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/My File.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('Unicode filename', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/节点/файл.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('hidden file (.git/HEAD)', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/.git/HEAD',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('filename containing .. as substring (not segment)', () => {
|
||||
for (const name of ['..hidden', 'file..txt', 'a..b/c']) {
|
||||
const r = validateEntry(
|
||||
'node_modules/' + name,
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('symlink within same root', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/.bin/cmd',
|
||||
'../foo/bin/cmd',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('symlink crossing into a different allowed root', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
// From /workspace/node_modules/, "../.cache/x" → /workspace/.cache/x (an allowed root)
|
||||
'../.cache/x',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('hardlink within same root', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/dup.js',
|
||||
// Hardlink target is relative to extraction CWD
|
||||
'node_modules/orig.js',
|
||||
'Link',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('path traversal attacks (must reject)', () => {
|
||||
test('classic ../../etc/passwd', () => {
|
||||
const r = validateEntry(
|
||||
'../../../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('inside-then-out traversal', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/../../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('hidden in middle', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/sub/../../../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('multiple slashes around .. still rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/..//../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('trailing .. inside root is allowed (does not escape)', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/sub/..',
|
||||
undefined,
|
||||
'Directory',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('absolute / UNC paths (must reject)', () => {
|
||||
test('POSIX absolute path', () => {
|
||||
const r = validateEntry(
|
||||
'/etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH')
|
||||
})
|
||||
|
||||
test('POSIX root', () => {
|
||||
const r = validateEntry('/', undefined, 'Directory', allowedRoots, cwd)
|
||||
expect(r.ok).toBe(false)
|
||||
})
|
||||
|
||||
test('Windows absolute path with backslash', () => {
|
||||
const r = validateEntry(
|
||||
'C:\\Windows\\System32\\drivers\\etc\\hosts',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH')
|
||||
})
|
||||
|
||||
test('Windows absolute path with forward slash', () => {
|
||||
const r = validateEntry(
|
||||
'C:/Windows/System32/drivers/etc/hosts',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH')
|
||||
})
|
||||
|
||||
test('Windows drive-relative (no slash)', () => {
|
||||
const r = validateEntry(
|
||||
'C:foo',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH')
|
||||
})
|
||||
|
||||
test('UNC path with backslashes', () => {
|
||||
const r = validateEntry(
|
||||
'\\\\attacker\\share\\payload',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNC_PATH')
|
||||
})
|
||||
|
||||
test('UNC path with forward slashes', () => {
|
||||
const r = validateEntry(
|
||||
'//attacker/share/payload',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNC_PATH')
|
||||
})
|
||||
|
||||
test('UNC long-path prefix', () => {
|
||||
const r = validateEntry(
|
||||
'\\\\?\\C:\\foo',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNC_PATH')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NUL byte attacks (must reject)', () => {
|
||||
test('NUL in path', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe\0/../etc/passwd',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('NUL_BYTE')
|
||||
})
|
||||
|
||||
test('NUL in symlink target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
'safe\0/etc/passwd',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('NUL_BYTE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('symlink/hardlink attacks (must reject)', () => {
|
||||
test('symlink with absolute target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
'/etc/passwd',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('symlink with .. traversal', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
'../../../etc/passwd',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('hardlink with absolute target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/dup',
|
||||
'/etc/passwd',
|
||||
'Link',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('hardlink with .. traversal', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/dup',
|
||||
'../../etc/passwd',
|
||||
'Link',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('symlink-then-write-through-link attack: symlink target outside roots is rejected', () => {
|
||||
// This is the critical "symlink-then-write" attack:
|
||||
// Entry 1: node_modules/x → /etc (a symlink target outside allowed roots)
|
||||
// Entry 2: node_modules/x/payload (which extracts THROUGH entry 1's link
|
||||
// and lands at /etc/payload)
|
||||
// We must reject Entry 1 because its target is outside the allowed roots.
|
||||
// Entry 2's nominal path looks safe, so we cannot rely on per-entry path
|
||||
// validation alone — we must catch the symlink target.
|
||||
const r = validateEntry(
|
||||
'node_modules/x',
|
||||
'/etc',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('LINK_OUTSIDE_ROOTS')
|
||||
})
|
||||
|
||||
test('symlink with empty target is allowed (filtered as undefined linkpath)', () => {
|
||||
// Empty string for linkpath is treated as "no link target to validate".
|
||||
const r = validateEntry(
|
||||
'node_modules/x',
|
||||
'',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('self-referential symlink (link to .)', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/x',
|
||||
'.',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
// "." resolves to the entry's directory which is under the root.
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupported entry types (must reject)', () => {
|
||||
test('CharacterDevice is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'CharacterDevice',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('BlockDevice is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'BlockDevice',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('FIFO is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'FIFO',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('ContiguousFile is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'ContiguousFile',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('unknown / future entry type is rejected by the allow-list', () => {
|
||||
// node-tar can surface an unknown typeflag byte as a non-standard
|
||||
// string. The allow-list approach guarantees we reject it rather than
|
||||
// silently passing it through to the extractor.
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'SomeFutureType',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('arbitrary typeflag-byte string is rejected', () => {
|
||||
// Simulate an attacker-supplied non-standard typeflag like '\u0001'
|
||||
// surfacing as a literal string from the tar parser.
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'\u0001',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
|
||||
test('empty entry type string is rejected', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
'',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('UNSUPPORTED_TYPE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('allow-listed entry types (must accept)', () => {
|
||||
test.each(['File', 'OldFile', 'Directory'])(
|
||||
'%s is accepted for an in-root path',
|
||||
entryType => {
|
||||
const r = validateEntry(
|
||||
'node_modules/safe',
|
||||
undefined,
|
||||
entryType,
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
test('SymbolicLink is accepted for an in-root target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/link',
|
||||
'real',
|
||||
'SymbolicLink',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('Link (hardlink) is accepted for an in-root target', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/hardlink',
|
||||
'node_modules/real',
|
||||
'Link',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('header-only entry types (must accept without validation)', () => {
|
||||
test('GlobalExtendedHeader is accepted regardless of path content', () => {
|
||||
// PAX global headers don't materialize as a file; their "path" is metadata.
|
||||
const r = validateEntry(
|
||||
'/anything/at/all',
|
||||
undefined,
|
||||
'GlobalExtendedHeader',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('ExtendedHeader is accepted', () => {
|
||||
const r = validateEntry(
|
||||
'PaxHeader/path-doesnt-matter',
|
||||
undefined,
|
||||
'ExtendedHeader',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('NextFileHasLongPath is accepted', () => {
|
||||
const r = validateEntry(
|
||||
'@LongLink',
|
||||
undefined,
|
||||
'NextFileHasLongPath',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('NextFileHasLongLinkpath is accepted', () => {
|
||||
const r = validateEntry(
|
||||
'@LongLink',
|
||||
undefined,
|
||||
'NextFileHasLongLinkpath',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('control characters in filenames (legal but unusual)', () => {
|
||||
test('embedded newline is treated literally as part of the segment', () => {
|
||||
// node-tar reports the full filename including the embedded newline.
|
||||
// It's a single segment under node_modules — must be accepted.
|
||||
const r = validateEntry(
|
||||
'node_modules/file\nwith newline',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
|
||||
test('embedded tab is accepted', () => {
|
||||
const r = validateEntry(
|
||||
'node_modules/file\twith tab',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('case sensitivity', () => {
|
||||
if (CASE_INSENSITIVE) {
|
||||
test('on Windows/macOS, NODE_MODULES matches node_modules', () => {
|
||||
const r = validateEntry(
|
||||
'NODE_MODULES/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(true)
|
||||
})
|
||||
} else {
|
||||
test('on Linux, NODE_MODULES does NOT match node_modules', () => {
|
||||
const r = validateEntry(
|
||||
'NODE_MODULES/foo.js',
|
||||
undefined,
|
||||
'File',
|
||||
allowedRoots,
|
||||
cwd
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('sibling-prefix non-collision', () => {
|
||||
test('an entry under /foo-extra is NOT accepted by allowed-root /foo', () => {
|
||||
const roots = [IS_WINDOWS ? 'C:\\workspace\\foo' : '/workspace/foo']
|
||||
const r = validateEntry(
|
||||
'../foo-extra/payload',
|
||||
undefined,
|
||||
'File',
|
||||
roots,
|
||||
IS_WINDOWS ? 'C:\\workspace\\foo' : '/workspace/foo'
|
||||
)
|
||||
expect(r.ok).toBe(false)
|
||||
if (!r.ok) expect(r.code).toBe('OUTSIDE_ROOTS')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatViolationSummary', () => {
|
||||
function v(reason: string): PathValidationViolation {
|
||||
return {
|
||||
path: 'x',
|
||||
resolved: '',
|
||||
entryType: 'File',
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
reason
|
||||
}
|
||||
}
|
||||
|
||||
test('empty list produces empty string', () => {
|
||||
expect(formatViolationSummary([])).toBe('')
|
||||
})
|
||||
|
||||
test('shows up to maxShown items verbatim', () => {
|
||||
const out = formatViolationSummary(
|
||||
[v('a'), v('b'), v('c')],
|
||||
5
|
||||
)
|
||||
expect(out).toContain(' - a')
|
||||
expect(out).toContain(' - b')
|
||||
expect(out).toContain(' - c')
|
||||
expect(out).not.toContain('more')
|
||||
})
|
||||
|
||||
test('truncates excess items with summary line', () => {
|
||||
const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map(v)
|
||||
const out = formatViolationSummary(items, 3)
|
||||
expect(out).toContain(' - a')
|
||||
expect(out).toContain(' - b')
|
||||
expect(out).toContain(' - c')
|
||||
expect(out).toContain('and 4 more')
|
||||
expect(out).not.toContain(' - d')
|
||||
})
|
||||
})
|
||||
+12
-3
@@ -166,7 +166,10 @@ test('restore with gzip compressed cache found', async () => {
|
||||
expect(getArchiveFileSizeInBytesMock).toHaveBeenCalledWith(archivePath)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
@@ -227,7 +230,10 @@ test('restore with zstd compressed cache found', async () => {
|
||||
expect(infoMock).toHaveBeenCalledWith(`Cache Size: ~60 MB (62915000 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -285,7 +291,10 @@ test('restore with cache found for restore key', async () => {
|
||||
expect(infoMock).toHaveBeenCalledWith(`Cache Size: ~0 MB (142 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compression, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
expect(getCompressionMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
||||
+12
-3
@@ -210,7 +210,10 @@ test('restore with gzip compressed cache found', async () => {
|
||||
expect(logInfoMock).toHaveBeenCalledWith(`Cache Size: ~0 MB (142 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
@@ -287,7 +290,10 @@ test('restore with zstd compressed cache found', async () => {
|
||||
expect(logInfoMock).toHaveBeenCalledWith(`Cache Size: ~60 MB (62915000 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
@@ -367,7 +373,10 @@ test('restore with cache found for restore key', async () => {
|
||||
expect(logInfoMock).toHaveBeenCalledWith(`Cache Size: ~0 MB (142 B)`)
|
||||
|
||||
expect(extractTarMock).toHaveBeenCalledTimes(1)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod)
|
||||
expect(extractTarMock).toHaveBeenCalledWith(archivePath, compressionMethod, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: undefined
|
||||
})
|
||||
|
||||
expect(unlinkFileMock).toHaveBeenCalledTimes(1)
|
||||
expect(unlinkFileMock).toHaveBeenCalledWith(archivePath)
|
||||
|
||||
+349
@@ -0,0 +1,349 @@
|
||||
import * as exec from '@actions/exec'
|
||||
import * as core from '@actions/core'
|
||||
import * as io from '@actions/io'
|
||||
import * as path from 'path'
|
||||
import {CompressionMethod} from '../src/internal/constants'
|
||||
import * as tar from '../src/internal/tar'
|
||||
import {CacheIntegrityError} from '../src/internal/cacheIntegrityError'
|
||||
import * as listAndValidate from '../src/internal/listAndValidate'
|
||||
|
||||
jest.mock('@actions/exec')
|
||||
jest.mock('@actions/io')
|
||||
jest.mock('../src/internal/listAndValidate')
|
||||
|
||||
function getTempDir(): string {
|
||||
return path.join(__dirname, '_temp', 'tarPathValidation')
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
jest.spyOn(io, 'which').mockImplementation(async tool => tool)
|
||||
process.env['GITHUB_WORKSPACE'] = process.cwd()
|
||||
await jest.requireActual('@actions/io').rmRF(getTempDir())
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
jest.spyOn(io, 'which').mockImplementation(async tool => tool)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env['GITHUB_WORKSPACE']
|
||||
await jest.requireActual('@actions/io').rmRF(getTempDir())
|
||||
})
|
||||
|
||||
const archive = 'cache.tar.gz'
|
||||
|
||||
describe('extractTar path validation integration', () => {
|
||||
describe("mode 'off' (default)", () => {
|
||||
test('does not call listAndValidate when no options passed', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip)
|
||||
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
expect(execMock).toHaveBeenCalledTimes(1) // system tar still runs
|
||||
})
|
||||
|
||||
test("explicit pathValidation='off' skips validation", async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'off'
|
||||
})
|
||||
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mode 'warn'", () => {
|
||||
test('clean archive: no warnings emitted, extraction proceeds', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('violations present: exactly one warning, debug per violation, extraction still proceeds', async () => {
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue([
|
||||
{
|
||||
path: '../escape.txt',
|
||||
resolved: '',
|
||||
entryType: 'File',
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
reason: 'escapes allowed roots'
|
||||
},
|
||||
{
|
||||
path: 'cache/link',
|
||||
linkpath: '/etc/passwd',
|
||||
resolved: '',
|
||||
entryType: 'SymbolicLink',
|
||||
code: 'LINK_OUTSIDE_ROOTS',
|
||||
reason: 'symlink target outside allowed roots'
|
||||
}
|
||||
])
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
const debugSpy = jest.spyOn(core, 'debug').mockImplementation()
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
// Exactly one summary warning
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/2 entries/)
|
||||
expect(warnSpy.mock.calls[0][0]).not.toMatch(/failed integrity/)
|
||||
// One debug entry per violation
|
||||
expect(debugSpy).toHaveBeenCalledTimes(2)
|
||||
expect(debugSpy.mock.calls[0][0]).toMatch(/path-validation/)
|
||||
// Extraction still happens
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('single violation: warning text uses singular wording', async () => {
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue([
|
||||
{
|
||||
path: '../boom.txt',
|
||||
resolved: '',
|
||||
entryType: 'File',
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
reason: 'escapes'
|
||||
}
|
||||
])
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
jest.spyOn(core, 'debug').mockImplementation()
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/1 entry/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mode 'error'", () => {
|
||||
test('violations present: throws CacheIntegrityError, system tar NEVER invoked', async () => {
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue([
|
||||
{
|
||||
path: '../etc/passwd',
|
||||
resolved: '',
|
||||
entryType: 'File',
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
reason: 'escapes allowed roots'
|
||||
}
|
||||
])
|
||||
jest.spyOn(core, 'warning').mockImplementation()
|
||||
jest.spyOn(core, 'debug').mockImplementation()
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
const mkdirMock = jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
||||
|
||||
await expect(
|
||||
tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
).rejects.toThrow(CacheIntegrityError)
|
||||
|
||||
// The critical security assertion: no extraction directory was created
|
||||
// and system tar was never invoked.
|
||||
expect(execMock).not.toHaveBeenCalled()
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('thrown error has code PATH_VIOLATION and exposes violations', async () => {
|
||||
const violations = [
|
||||
{
|
||||
path: '../boom.txt',
|
||||
resolved: '',
|
||||
entryType: 'File' as const,
|
||||
code: 'OUTSIDE_ROOTS' as const,
|
||||
reason: 'escapes'
|
||||
}
|
||||
]
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue(violations)
|
||||
jest.spyOn(core, 'warning').mockImplementation()
|
||||
jest.spyOn(core, 'debug').mockImplementation()
|
||||
|
||||
try {
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
fail('expected CacheIntegrityError')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CacheIntegrityError)
|
||||
const e = err as CacheIntegrityError
|
||||
expect(e.code).toBe('PATH_VIOLATION')
|
||||
expect(e.violations).toEqual(violations)
|
||||
expect(e.message).toMatch(/Refusing to extract/)
|
||||
}
|
||||
})
|
||||
|
||||
test('clean archive: no throw, extraction proceeds normally', async () => {
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled()
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('listAndValidate throws parse error: wrapped as CacheIntegrityError(PARSE_ERROR), no extraction', async () => {
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockRejectedValue(new Error('tar parse error (TAR_BAD_ARCHIVE): bad'))
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
const mkdirMock = jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
||||
|
||||
try {
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
fail('expected throw')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CacheIntegrityError)
|
||||
expect((err as CacheIntegrityError).code).toBe('PARSE_ERROR')
|
||||
expect((err as CacheIntegrityError).message).toMatch(
|
||||
/tar parse error/
|
||||
)
|
||||
}
|
||||
expect(execMock).not.toHaveBeenCalled()
|
||||
expect(mkdirMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("listAndValidate parse failure in 'warn' mode: logs warning, skips validation, extraction proceeds", async () => {
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockRejectedValue(new Error('bad bytes'))
|
||||
const execMock = jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
const warnSpy = jest.spyOn(core, 'warning').mockImplementation()
|
||||
|
||||
// In 'warn' mode a parse failure is non-fatal: the validator's tar
|
||||
// parser is stricter than the system `tar` that performs the actual
|
||||
// extraction, so the archive may still extract cleanly. We log a
|
||||
// warning and proceed.
|
||||
await expect(
|
||||
tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/integrity check failed/)
|
||||
expect(warnSpy.mock.calls[0][0]).toMatch(/bad bytes/)
|
||||
expect(execMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('allowed-roots derivation', () => {
|
||||
test('declaredPaths is forwarded to listAndValidate', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['build/', 'node_modules/'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
const [, , allowedRoots] = listMock.mock.calls[0]
|
||||
// Both declared roots should appear after resolution
|
||||
expect(allowedRoots.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test('missing declaredPaths falls back to workspace as the sole allowed root', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
const [, , allowedRoots, extractCwd] = listMock.mock.calls[0]
|
||||
// Empty declaredPaths array → fail-safe fallback to extractCwd
|
||||
expect(allowedRoots).toEqual([extractCwd])
|
||||
})
|
||||
})
|
||||
|
||||
describe('compression method forwarding', () => {
|
||||
test('Gzip', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock.mock.calls[0][1]).toBe(CompressionMethod.Gzip)
|
||||
})
|
||||
|
||||
test('Zstd', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Zstd, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock.mock.calls[0][1]).toBe(CompressionMethod.Zstd)
|
||||
})
|
||||
|
||||
test('ZstdWithoutLong', async () => {
|
||||
const listMock = jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue([])
|
||||
jest.spyOn(exec, 'exec').mockResolvedValue(0)
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.ZstdWithoutLong, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(listMock.mock.calls[0][1]).toBe(
|
||||
CompressionMethod.ZstdWithoutLong
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
# Path Validation Test Plan — `@actions/cache`
|
||||
|
||||
This document describes the test coverage for the client-side cache-archive path
|
||||
validation feature introduced in `@actions/cache` v6.1.0.
|
||||
|
||||
## Feature summary
|
||||
|
||||
`extractTar()` now accepts a third argument:
|
||||
|
||||
```ts
|
||||
extractTar(archivePath, compressionMethod, {
|
||||
declaredPaths?: string[],
|
||||
pathValidation?: 'off' | 'warn' | 'error'
|
||||
})
|
||||
```
|
||||
|
||||
When `pathValidation !== 'off'`, the archive is streamed through `node-tar`'s
|
||||
`Parser` (no extraction) and every entry's path / linkpath is checked against
|
||||
the set of allowed roots derived from `declaredPaths` (or, if that list is
|
||||
empty, the GitHub Actions workspace as a single fail-safe root). Violations are
|
||||
collected; in `'error'` mode a `CacheIntegrityError` is thrown **before** system
|
||||
tar is invoked, so no bytes are ever written to the workspace.
|
||||
|
||||
`restoreCacheV1` and `restoreCacheV2` forward the caller-supplied
|
||||
`pathValidation` mode and the declared `paths` array to `extractTar`. They also
|
||||
re-throw `CacheIntegrityError` instances unchanged, so callers can distinguish
|
||||
integrity failures from ordinary cache-miss/network errors.
|
||||
|
||||
## Files under test
|
||||
|
||||
| Source | Tests |
|
||||
|---|---|
|
||||
| [`src/internal/pathValidation.ts`](../src/internal/pathValidation.ts) | [`__tests__/pathValidation.test.ts`](../__tests__/pathValidation.test.ts) |
|
||||
| [`src/internal/listAndValidate.ts`](../src/internal/listAndValidate.ts) | [`__tests__/listAndValidate.test.ts`](../__tests__/listAndValidate.test.ts) |
|
||||
| [`src/internal/tar.ts`](../src/internal/tar.ts) (integration into `extractTar`) | [`__tests__/tarPathValidation.test.ts`](../__tests__/tarPathValidation.test.ts) |
|
||||
| [`src/internal/cacheIntegrityError.ts`](../src/internal/cacheIntegrityError.ts) | covered indirectly via the integration tests |
|
||||
| [`src/options.ts`](../src/options.ts) (new `pathValidation` field) | [`__tests__/options.test.ts`](../__tests__/options.test.ts) |
|
||||
| [`src/cache.ts`](../src/cache.ts) (forwarding + error re-throw) | [`__tests__/restoreCache.test.ts`](../__tests__/restoreCache.test.ts), [`__tests__/restoreCacheV2.test.ts`](../__tests__/restoreCacheV2.test.ts) |
|
||||
|
||||
## Unit tests — pure logic (`pathValidation.test.ts`, 86 cases)
|
||||
|
||||
These exercise the platform-agnostic validation logic with no filesystem or
|
||||
network. The suite is split into two groups.
|
||||
|
||||
### `deriveAllowedRoots`
|
||||
|
||||
Verifies the longest-non-glob-prefix derivation, normalization, deduplication
|
||||
and platform-specific behavior:
|
||||
|
||||
- **Glob-prefix stripping**: `cache/**`, `*.log`, `?abc`, `[abc]`, `{a,b}`, `!neg`
|
||||
- **Path expansion**:
|
||||
- `~` and `~/...` → home directory
|
||||
- `$VAR`, `${VAR}`, `%VAR%` → environment variables (with unknown vars expanding to empty)
|
||||
- Mixed expansion-before-glob (`${VAR}` is not misread as a brace glob)
|
||||
- **Normalization**: leading `./`, embedded `./`, embedded `../`
|
||||
- **Negations**: any pattern starting with `!` is dropped
|
||||
- **Edge inputs**: `.`, empty string, whitespace-only, undefined entries
|
||||
- **Deduplication**:
|
||||
- Identical roots collapsed
|
||||
- Child roots subsumed by parents (`/a/b` dropped when `/a` is also present)
|
||||
- Sibling-prefix non-collision (`/aa` is NOT subsumed by `/a`)
|
||||
- **Case sensitivity**: case-insensitive on Windows/macOS, case-sensitive on Linux
|
||||
|
||||
### `validateEntry` — accept
|
||||
|
||||
- Plain files under allowed roots (`cache/file.txt`)
|
||||
- Nested files (`cache/sub1/sub2/.../file.txt`)
|
||||
- Files with leading `./`
|
||||
- Files containing `..` as a substring within a segment (`..hidden`, `foo..bar`)
|
||||
- Files in any of multiple allowed roots
|
||||
- Unicode filenames, files with spaces, hidden files (`.git/HEAD`)
|
||||
- Trailing `..` segments that don't escape the root
|
||||
- Symlinks where the resolved target stays inside an allowed root
|
||||
- Symlinks crossing from one allowed root into another allowed root
|
||||
- Hardlinks within the same root
|
||||
- Empty linkpath (treated as "no target to validate")
|
||||
|
||||
### `validateEntry` — reject (security)
|
||||
|
||||
- **Path traversal**: classic `../../etc/passwd`, inside-then-out (`cache/../../etc/x`),
|
||||
hidden in middle, multi-slash separators (`cache//../../x`)
|
||||
- **Absolute paths**: POSIX `/etc/x`, root `/`, Windows `C:\...`, Windows forward-slash
|
||||
`C:/...`, Windows drive-relative `C:foo`, UNC `\\server\share`, UNC forward-slash,
|
||||
UNC long-path prefix `\\?\C:\...`
|
||||
- **NUL byte attacks**: NUL in path, NUL in symlink target
|
||||
- **Symlink attacks**:
|
||||
- Absolute symlink target
|
||||
- Symlink target with `..` traversal
|
||||
- **Symlink-then-write-through-link** (the critical TOCTOU-style attack:
|
||||
archive declares `cache/link → /tmp/evil` followed by `cache/link/file`)
|
||||
- Self-referential symlink to `.`
|
||||
- **Hardlink attacks**: absolute target, `..` traversal
|
||||
- **Unsupported entry types**: `CharacterDevice`, `BlockDevice`, `FIFO`
|
||||
- **Header-only types accepted unconditionally** (these carry no path content):
|
||||
`GlobalExtendedHeader`, `ExtendedHeader`, `NextFileHasLongPath`, `NextFileHasLongLinkpath`
|
||||
|
||||
### `formatViolationSummary`
|
||||
|
||||
- Empty list → empty string
|
||||
- Shows up to N items verbatim
|
||||
- Truncates the tail with a `(... and N more)` summary line
|
||||
|
||||
## Integration tests — real archives (`listAndValidate.test.ts`, 14 cases)
|
||||
|
||||
These build small tar archives in memory using `tar.Header`, write them to disk,
|
||||
and run them through the production parser. They cover the gzip and zstd
|
||||
codepaths and use the system `zstd` binary (matching production behavior).
|
||||
Skipped on hosts without `zstd` installed.
|
||||
|
||||
### Gzip-compressed
|
||||
|
||||
- Clean multi-entry archive → 0 violations
|
||||
- Single tiny-file archive → 0 violations
|
||||
- Classic `../../../etc/passwd` traversal → 1 violation, correct path/type
|
||||
- Absolute file path (`/etc/cron.d/evil` or `C:/Windows/...`) → 1 violation
|
||||
- Symlink with absolute target → 1 violation, correct linkpath captured
|
||||
- Symlink with traversing target → 1 violation
|
||||
- Hardlink with traversing target → 1 violation
|
||||
- Mixed clean + malicious entries → only bad ones reported
|
||||
- Character-device entry → 1 violation
|
||||
- Corrupted / non-tar bytes → throws `Error` (caller wraps as `PARSE_ERROR`)
|
||||
|
||||
### Zstd-compressed (long & short window)
|
||||
|
||||
- Clean archive compressed with `zstd --long=30` → 0 violations
|
||||
- Traversal in zstd archive → 1 violation
|
||||
- `ZstdWithoutLong` compression method also works
|
||||
|
||||
## Integration tests — mocked downstream (`tarPathValidation.test.ts`, 15 cases)
|
||||
|
||||
These mock `listAndValidate` so the test can deterministically inject "violation
|
||||
lists" and observe `extractTar`'s reaction. They mock `@actions/exec`,
|
||||
`@actions/io` and `@actions/core` to assert what does (and does not) get called.
|
||||
|
||||
### `pathValidation: 'off'` (default)
|
||||
|
||||
- No `options` argument → validator never called, system tar runs normally
|
||||
- Explicit `'off'` → validator never called, system tar runs
|
||||
|
||||
### `pathValidation: 'warn'`
|
||||
|
||||
- Clean archive → no warning emitted, extraction proceeds
|
||||
- Violations present → **exactly one** `core.warning` summary, one `core.debug`
|
||||
per violation, system tar **still runs**
|
||||
- Single violation → warning uses singular wording (`1 entry`)
|
||||
|
||||
### `pathValidation: 'error'`
|
||||
|
||||
- Violations present → throws `CacheIntegrityError`, **system tar is never
|
||||
invoked, `mkdirP` is never called** (no extraction directory is created)
|
||||
- Thrown error has `code === 'PATH_VIOLATION'` and exposes the violations array
|
||||
- Clean archive → no warning, no throw, extraction proceeds
|
||||
- `listAndValidate` throws → wrapped as `CacheIntegrityError(PARSE_ERROR)`,
|
||||
system tar not invoked
|
||||
- Parse failure in `'warn'` mode → still wrapped as `CacheIntegrityError(PARSE_ERROR)`
|
||||
and surfaced (we can't trust the archive when we can't even read it)
|
||||
|
||||
### Plumbing
|
||||
|
||||
- `declaredPaths` is forwarded to `listAndValidate`
|
||||
- Empty/missing `declaredPaths` → fall back to `[workingDirectory]`
|
||||
- All three compression methods (`Gzip`, `Zstd`, `ZstdWithoutLong`) forward correctly
|
||||
|
||||
## Regression coverage
|
||||
|
||||
These pre-existing tests were updated to account for the new third argument to
|
||||
`extractTar` but otherwise exercise the same behavior as before:
|
||||
|
||||
- `restoreCache.test.ts` — V1 restore plumbs `paths` and `pathValidation`
|
||||
- `restoreCacheV2.test.ts` — V2 restore plumbs `paths` and `pathValidation`
|
||||
- `options.test.ts` — `getDownloadOptions` defaults `pathValidation: 'off'`
|
||||
|
||||
## What is intentionally NOT tested at this layer
|
||||
|
||||
- **End-to-end behaviour with real cache backend** — covered by the
|
||||
`actions/cache` action's E2E workflow (matrix across ubuntu, macos, windows ×
|
||||
off/warn/error).
|
||||
- **Action-level input parsing** (`strict-paths`, `fail-on-cache-invalid`) —
|
||||
lives in the action repo's `actionUtils` + `restoreImpl` tests.
|
||||
- **Symlink resolution against the live filesystem** — by design. We validate
|
||||
the *declared* paths from the archive header, not what they would resolve to
|
||||
on the running host. Live-fs resolution would re-introduce the TOCTOU window
|
||||
this feature exists to close.
|
||||
|
||||
## Running the tests
|
||||
|
||||
```sh
|
||||
# from the toolkit repo root
|
||||
npx jest --testTimeout 70000 packages/cache
|
||||
|
||||
# just the new path-validation suites
|
||||
npx jest --testTimeout 70000 \
|
||||
packages/cache/__tests__/pathValidation.test.ts \
|
||||
packages/cache/__tests__/listAndValidate.test.ts \
|
||||
packages/cache/__tests__/tarPathValidation.test.ts
|
||||
```
|
||||
|
||||
Total path-validation test cases: **115** (86 unit + 14 real-archive + 15 mocked).
|
||||
Combined with the regression updates, the cache package runs **237 tests** total.
|
||||
+69
-1
@@ -17,7 +17,8 @@
|
||||
"@azure/core-rest-pipeline": "^1.23.0",
|
||||
"@azure/storage-blob": "^12.31.0",
|
||||
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
||||
"semver": "^7.7.4"
|
||||
"semver": "^7.7.4",
|
||||
"tar": "^7.5.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@protobuf-ts/plugin": "^2.11.1",
|
||||
@@ -306,6 +307,18 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/fs-minipass": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"minipass": "^7.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodable/entities": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
|
||||
@@ -445,6 +458,15 @@
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -552,6 +574,27 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/minizlib": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
|
||||
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minipass": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -597,6 +640,22 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.15",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz",
|
||||
"integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
"chownr": "^3.0.0",
|
||||
"minipass": "^7.1.2",
|
||||
"minizlib": "^3.1.0",
|
||||
"yallist": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
@@ -656,6 +715,15 @@
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+7
-2
@@ -15,7 +15,8 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/cache.d.ts",
|
||||
"import": "./lib/cache.js"
|
||||
"import": "./lib/cache.js",
|
||||
"default": "./lib/cache.js"
|
||||
}
|
||||
},
|
||||
"directories": {
|
||||
@@ -42,6 +43,9 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/actions/toolkit/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": "^3.0.1",
|
||||
"@actions/exec": "^3.0.0",
|
||||
@@ -51,7 +55,8 @@
|
||||
"@azure/core-rest-pipeline": "^1.23.0",
|
||||
"@azure/storage-blob": "^12.31.0",
|
||||
"@protobuf-ts/runtime-rpc": "^2.11.1",
|
||||
"semver": "^7.7.4"
|
||||
"semver": "^7.7.4",
|
||||
"tar": "^7.5.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@protobuf-ts/plugin": "^2.11.1",
|
||||
|
||||
Vendored
+39
-2
@@ -6,6 +6,7 @@ import * as cacheTwirpClient from './internal/shared/cacheTwirpClient.js'
|
||||
import {getCacheServiceVersion, isGhes} from './internal/config.js'
|
||||
import {DownloadOptions, UploadOptions} from './options.js'
|
||||
import {createTar, extractTar, listTar} from './internal/tar.js'
|
||||
import {CacheIntegrityError} from './internal/cacheIntegrityError.js'
|
||||
import {
|
||||
CreateCacheEntryRequest,
|
||||
FinalizeCacheEntryUploadRequest,
|
||||
@@ -15,6 +16,14 @@ import {
|
||||
import {HttpClientError} from '@actions/http-client'
|
||||
|
||||
export type {DownloadOptions, UploadOptions}
|
||||
export {CacheIntegrityError}
|
||||
export type {
|
||||
CacheIntegrityErrorCode
|
||||
} from './internal/cacheIntegrityError.js'
|
||||
export type {
|
||||
PathValidationMode,
|
||||
PathValidationViolation
|
||||
} from './internal/pathValidation.js'
|
||||
export class ValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
@@ -121,6 +130,10 @@ export function isFeatureAvailable(): boolean {
|
||||
* @param downloadOptions cache download options
|
||||
* @param enableCrossOsArchive an optional boolean enabled to restore on windows any cache created on any platform
|
||||
* @returns string returns the key for the cache hit, otherwise returns undefined
|
||||
* @throws {ValidationError} If paths is empty, any key is longer than 512 characters or contains a comma,
|
||||
* or more than 10 keys are provided.
|
||||
* @throws {CacheIntegrityError} If options.pathValidation is 'error' and a parse error or path violation
|
||||
* is detected in the cache archive before extraction.
|
||||
*/
|
||||
export async function restoreCache(
|
||||
paths: string[],
|
||||
@@ -164,6 +177,10 @@ export async function restoreCache(
|
||||
* @param options cache download options
|
||||
* @param enableCrossOsArchive an optional boolean enabled to restore on Windows any cache created on any platform
|
||||
* @returns string returns the key for the cache hit, otherwise returns undefined
|
||||
* @throws {ValidationError} If paths is empty, any key is longer than 512 characters or contains a comma,
|
||||
* or more than 10 keys are provided.
|
||||
* @throws {CacheIntegrityError} If options.pathValidation is 'error' and a parse error or path violation
|
||||
* is detected in the cache archive before extraction.
|
||||
*/
|
||||
async function restoreCacheV1(
|
||||
paths: string[],
|
||||
@@ -229,7 +246,10 @@ async function restoreCacheV1(
|
||||
)} MB (${archiveFileSize} B)`
|
||||
)
|
||||
|
||||
await extractTar(archivePath, compressionMethod)
|
||||
await extractTar(archivePath, compressionMethod, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: options?.pathValidation
|
||||
})
|
||||
core.info('Cache restored successfully')
|
||||
|
||||
return cacheEntry.cacheKey
|
||||
@@ -237,6 +257,11 @@ async function restoreCacheV1(
|
||||
const typedError = error as Error
|
||||
if (typedError.name === ValidationError.name) {
|
||||
throw error
|
||||
} else if (typedError instanceof CacheIntegrityError) {
|
||||
// Integrity errors are surfaced to the caller (e.g. the cache action)
|
||||
// so it can choose to fail the step rather than silently treating the
|
||||
// restore as a cache miss.
|
||||
throw error
|
||||
} else {
|
||||
// warn on cache restore failure and continue build
|
||||
// Log server errors (5xx) as errors, all other errors as warnings
|
||||
@@ -271,6 +296,10 @@ async function restoreCacheV1(
|
||||
* @param downloadOptions cache download options
|
||||
* @param enableCrossOsArchive an optional boolean enabled to restore on windows any cache created on any platform
|
||||
* @returns string returns the key for the cache hit, otherwise returns undefined
|
||||
* @throws {ValidationError} If paths is empty, any key is longer than 512 characters or contains a comma,
|
||||
* or more than 10 keys are provided.
|
||||
* @throws {CacheIntegrityError} If options.pathValidation is 'error' and a parse error or path violation
|
||||
* is detected in the cache archive before extraction.
|
||||
*/
|
||||
async function restoreCacheV2(
|
||||
paths: string[],
|
||||
@@ -361,7 +390,10 @@ async function restoreCacheV2(
|
||||
await listTar(archivePath, compressionMethod)
|
||||
}
|
||||
|
||||
await extractTar(archivePath, compressionMethod)
|
||||
await extractTar(archivePath, compressionMethod, {
|
||||
declaredPaths: paths,
|
||||
pathValidation: options?.pathValidation
|
||||
})
|
||||
core.info('Cache restored successfully')
|
||||
|
||||
return response.matchedKey
|
||||
@@ -369,6 +401,11 @@ async function restoreCacheV2(
|
||||
const typedError = error as Error
|
||||
if (typedError.name === ValidationError.name) {
|
||||
throw error
|
||||
} else if (typedError instanceof CacheIntegrityError) {
|
||||
// Integrity errors are surfaced to the caller (e.g. the cache action)
|
||||
// so it can choose to fail the step rather than silently treating the
|
||||
// restore as a cache miss.
|
||||
throw error
|
||||
} else {
|
||||
// Supress all non-validation cache related errors because caching should be optional
|
||||
// Log server errors (5xx) as errors, all other errors as warnings
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {PathValidationViolation} from './pathValidation.js'
|
||||
|
||||
/**
|
||||
* Reason codes for cache integrity failures. Useful for callers (e.g. the
|
||||
* `actions/cache` action) that want to distinguish between recoverable
|
||||
* conditions and unambiguous corruption.
|
||||
*/
|
||||
export type CacheIntegrityErrorCode =
|
||||
| 'PARSE_ERROR' // archive could not be opened or parsed
|
||||
| 'PATH_VIOLATION' // path validation rejected one or more entries
|
||||
| 'CHECKSUM_MISMATCH' // reserved for future checksum-based integrity checks
|
||||
|
||||
/**
|
||||
* Thrown when a downloaded cache archive fails an integrity check. Today
|
||||
* this covers parse errors and path-validation failures (when the caller
|
||||
* has opted into `pathValidation: 'error'`).
|
||||
*
|
||||
* Callers can `instanceof`-check this class to distinguish integrity
|
||||
* failures from transient transport errors.
|
||||
*/
|
||||
export class CacheIntegrityError extends Error {
|
||||
readonly code: CacheIntegrityErrorCode
|
||||
readonly violations?: PathValidationViolation[]
|
||||
|
||||
constructor(
|
||||
code: CacheIntegrityErrorCode,
|
||||
message: string,
|
||||
violations?: PathValidationViolation[]
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'CacheIntegrityError'
|
||||
this.code = code
|
||||
this.violations = violations
|
||||
Object.setPrototypeOf(this, CacheIntegrityError.prototype)
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import {spawn} from 'child_process'
|
||||
import {createReadStream} from 'fs'
|
||||
import {Readable} from 'stream'
|
||||
import {pipeline} from 'stream/promises'
|
||||
import {Parser, ReadEntry} from 'tar'
|
||||
import {CompressionMethod} from './constants.js'
|
||||
import {
|
||||
PathValidationViolation,
|
||||
prepareAllowedRoots,
|
||||
validateEntry
|
||||
} from './pathValidation.js'
|
||||
|
||||
/**
|
||||
* Stream the entries of a (possibly compressed) tar archive and validate each
|
||||
* against the allowed roots. Does NOT extract any files — entries are read
|
||||
* for header inspection only. Returns the list of violations (empty if the
|
||||
* archive is clean).
|
||||
*
|
||||
* Throws an Error if the archive cannot be parsed (corrupt header,
|
||||
* decompression failure, truncated stream, etc.). The caller is responsible
|
||||
* for translating that into a CacheIntegrityError with code `PARSE_ERROR`.
|
||||
*/
|
||||
export async function listAndValidate(
|
||||
archivePath: string,
|
||||
compressionMethod: CompressionMethod,
|
||||
allowedRoots: string[],
|
||||
extractCwd: string
|
||||
): Promise<PathValidationViolation[]> {
|
||||
const violations: PathValidationViolation[] = []
|
||||
|
||||
// Precompute the normalized / case-folded form of each allowed root once,
|
||||
// so the per-entry containment check is a handful of string compares
|
||||
// rather than an O(roots) re-normalization on every entry.
|
||||
const preparedRoots = prepareAllowedRoots(allowedRoots)
|
||||
|
||||
// Captured parse error (if any). We don't throw synchronously from inside
|
||||
// the parser's onwarn callback because doing so leaves the parser's
|
||||
// internal stream in a half-broken state that hangs the surrounding
|
||||
// pipeline. Instead we record the first critical warning and throw it
|
||||
// after the pipeline completes.
|
||||
let firstParseError: Error | undefined
|
||||
|
||||
const recordParseError = (code: string, message: string): void => {
|
||||
if (firstParseError) return
|
||||
firstParseError = new Error(`tar parse error (${code}): ${message}`)
|
||||
}
|
||||
|
||||
// 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
|
||||
// we get the same `--long=30` window-size handling as the existing
|
||||
// extract codepath in tar.ts and avoid relying on Node's experimental
|
||||
// zstd support.
|
||||
const useNativeGzip = compressionMethod === CompressionMethod.Gzip
|
||||
const parser = new Parser({
|
||||
gzip: useNativeGzip,
|
||||
// Disable strict mode so recoverable warnings (e.g. unknown extended
|
||||
// headers) don't abort parsing. Real corruption is surfaced explicitly
|
||||
// via the captured error below.
|
||||
strict: false,
|
||||
// Treat structural problems (bad archive, bad header, bad chksum) as
|
||||
// hard parse errors — silently ignoring them would let a corrupt
|
||||
// archive sail through validation. We DO NOT throw on softer warnings
|
||||
// (extended headers, unknown PAX keys, etc.).
|
||||
onwarn: (code, message) => {
|
||||
if (
|
||||
code === 'TAR_BAD_ARCHIVE' ||
|
||||
code === 'TAR_ENTRY_INVALID' ||
|
||||
code === 'TAR_ENTRY_ERROR' ||
|
||||
code === 'TAR_ABORT'
|
||||
) {
|
||||
recordParseError(code, message)
|
||||
}
|
||||
},
|
||||
onReadEntry: (entry: ReadEntry) => {
|
||||
try {
|
||||
const result = validateEntry(
|
||||
entry.path,
|
||||
entry.linkpath || undefined,
|
||||
entry.type,
|
||||
preparedRoots,
|
||||
extractCwd
|
||||
)
|
||||
if (!result.ok) {
|
||||
violations.push({
|
||||
path: entry.path,
|
||||
linkpath: entry.linkpath || undefined,
|
||||
resolved: result.resolved,
|
||||
entryType: entry.type,
|
||||
code: result.code,
|
||||
reason: result.reason
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
// Drain the entry so the parser advances. Without this the stream
|
||||
// stalls waiting for the consumer to read the entry body.
|
||||
entry.resume()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await streamArchiveTo(archivePath, compressionMethod, parser)
|
||||
|
||||
if (firstParseError) {
|
||||
throw firstParseError
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
async function streamArchiveTo(
|
||||
archivePath: string,
|
||||
compressionMethod: CompressionMethod,
|
||||
destination: NodeJS.WritableStream
|
||||
): Promise<void> {
|
||||
const fileStream = createReadStream(archivePath)
|
||||
|
||||
if (compressionMethod === CompressionMethod.Gzip) {
|
||||
// node-tar's Parser was constructed with `gzip: true`, so it handles
|
||||
// decompression internally — just pipe the raw file stream in.
|
||||
await pipeline(fileStream, destination)
|
||||
return
|
||||
}
|
||||
|
||||
// zstd-compressed archive. node-tar does not natively decompress zstd, so
|
||||
// we shell out to the `zstd` binary the same way tar.ts does for the
|
||||
// existing extract codepath. This adds one extra decompression vs. the
|
||||
// existing extract step (which runs its own zstd), but only on archives
|
||||
// where path validation is enabled.
|
||||
const zstdArgs: string[] = ['-d', '-c']
|
||||
if (compressionMethod === CompressionMethod.Zstd) {
|
||||
zstdArgs.unshift('--long=30')
|
||||
}
|
||||
|
||||
const zstd = spawn('zstd', zstdArgs, {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
// Cap stderr capture so a chatty/malicious zstd invocation can't grow
|
||||
// memory without bound. 64 KiB is plenty for any realistic error message.
|
||||
const STDERR_CAP_BYTES = 64 * 1024
|
||||
let zstdStderr = ''
|
||||
let stderrBytes = 0
|
||||
let stderrTruncated = false
|
||||
zstd.stderr.on('data', (chunk: Buffer) => {
|
||||
if (stderrBytes >= STDERR_CAP_BYTES) {
|
||||
stderrTruncated = true
|
||||
return
|
||||
}
|
||||
const remaining = STDERR_CAP_BYTES - stderrBytes
|
||||
if (chunk.length > remaining) {
|
||||
zstdStderr += chunk.subarray(0, remaining).toString()
|
||||
stderrBytes += remaining
|
||||
stderrTruncated = true
|
||||
} else {
|
||||
zstdStderr += chunk.toString()
|
||||
stderrBytes += chunk.length
|
||||
}
|
||||
})
|
||||
|
||||
const zstdExited = new Promise<void>((resolve, reject) => {
|
||||
zstd.on('error', reject)
|
||||
zstd.on('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
} else {
|
||||
// A SIGTERM here means we killed the child ourselves during cleanup
|
||||
// after an upstream failure — surface a clearer message in that
|
||||
// case rather than the bare exit code.
|
||||
const cause =
|
||||
signal !== null
|
||||
? `terminated by signal ${signal}`
|
||||
: `exited with code ${code}`
|
||||
const tail = stderrTruncated ? ' (stderr truncated)' : ''
|
||||
reject(
|
||||
new Error(
|
||||
`zstd ${cause}${
|
||||
zstdStderr ? `: ${zstdStderr.trim()}` : ''
|
||||
}${tail}`
|
||||
)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const stdin = zstd.stdin as unknown as NodeJS.WritableStream
|
||||
const stdout = zstd.stdout as unknown as Readable
|
||||
|
||||
// Run both sides of the pipeline concurrently, plus wait for the zstd
|
||||
// process itself to exit cleanly. If decompression produces non-tar bytes
|
||||
// the destination parser will reject; if zstd exits non-zero the exit
|
||||
// promise will reject. Either way we surface the error.
|
||||
const inPromise = pipeline(fileStream, stdin)
|
||||
const outPromise = pipeline(stdout, destination)
|
||||
// Suppress unhandled-rejection warnings on the individual promises. The
|
||||
// first rejection is propagated via the Promise.all below; any later
|
||||
// rejection (e.g. zstd reporting a non-zero exit after the parser
|
||||
// already errored) would otherwise crash the process.
|
||||
inPromise.catch(() => undefined)
|
||||
outPromise.catch(() => undefined)
|
||||
zstdExited.catch(() => undefined)
|
||||
|
||||
try {
|
||||
await Promise.all([inPromise, outPromise, zstdExited])
|
||||
} finally {
|
||||
if (zstd.exitCode === null && zstd.signalCode === null && !zstd.killed) {
|
||||
zstd.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
}
|
||||
+522
@@ -0,0 +1,522 @@
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
|
||||
/**
|
||||
* The validation mode controlling how off-root archive entries are handled
|
||||
* during cache restore.
|
||||
*/
|
||||
export type PathValidationMode = 'off' | 'warn' | 'error'
|
||||
|
||||
/**
|
||||
* Describes a single archive entry that fails validation.
|
||||
*/
|
||||
export interface PathValidationViolation {
|
||||
/** Path of the offending archive entry, as reported by the tar parser. */
|
||||
path: string
|
||||
/** Link target for symlink/hardlink entries (undefined for regular files). */
|
||||
linkpath?: string
|
||||
/** Resolved absolute path that triggered the violation. */
|
||||
resolved: string
|
||||
/** Tar entry type (File, Directory, SymbolicLink, Link, etc.). */
|
||||
entryType: string
|
||||
/** Machine-readable reason code. */
|
||||
code:
|
||||
| 'ABSOLUTE_PATH'
|
||||
| 'UNC_PATH'
|
||||
| 'NUL_BYTE'
|
||||
| 'OUTSIDE_ROOTS'
|
||||
| 'LINK_OUTSIDE_ROOTS'
|
||||
| 'UNSUPPORTED_TYPE'
|
||||
/** Human-readable description of the violation. */
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of validating a single archive entry.
|
||||
*/
|
||||
export type ValidationResult =
|
||||
| {ok: true}
|
||||
| {
|
||||
ok: false
|
||||
code: PathValidationViolation['code']
|
||||
resolved: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-computed form of an allowed-roots list, suitable for repeated
|
||||
* containment checks against many archive entries. Produced by
|
||||
* {@link prepareAllowedRoots}.
|
||||
*
|
||||
* Holds the case-sensitivity flag and per-root normalized + comparison
|
||||
* strings, so {@link validateEntry} can normalize each child path once and
|
||||
* compare against pre-baked strings instead of re-normalizing/lower-casing
|
||||
* the parent on every entry.
|
||||
*/
|
||||
export interface PreparedAllowedRoots {
|
||||
readonly caseInsensitive: boolean
|
||||
readonly roots: ReadonlyArray<PreparedRoot>
|
||||
}
|
||||
|
||||
interface PreparedRoot {
|
||||
/** Normalized parent path, kept verbatim for diagnostics. */
|
||||
readonly normParent: string
|
||||
/** Normalized parent in comparison form (lowercased iff case-insensitive). */
|
||||
readonly normParentCmp: string
|
||||
/** Normalized parent with trailing separator, in comparison form. */
|
||||
readonly parentWithSepCmp: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tar entry types that produce a real file/directory/link on disk and are
|
||||
* considered safe to extract from a cache archive. Anything outside this set
|
||||
* (other than header-only metadata entries below) is rejected by the
|
||||
* validator.
|
||||
*
|
||||
* This is intentionally an allow-list rather than a block-list: the cache
|
||||
* format only ever needs files, directories, and links, and a paranoid
|
||||
* security check should reject unknown entry types by default rather than
|
||||
* silently passing through anything node-tar happens to surface from a
|
||||
* non-standard typeflag byte.
|
||||
*/
|
||||
const ALLOWED_ENTRY_TYPES = new Set([
|
||||
'File',
|
||||
'OldFile', // legacy ustar regular-file typeflag
|
||||
'Directory',
|
||||
'SymbolicLink',
|
||||
'Link' // hardlink — common in node_modules caches via npm
|
||||
])
|
||||
|
||||
/**
|
||||
* Tar entry types that describe metadata, not actual file content. These are
|
||||
* absorbed by the parser into the next real entry and do not produce a file on
|
||||
* disk, so we skip path validation for them.
|
||||
*/
|
||||
const HEADER_ONLY_ENTRY_TYPES = new Set([
|
||||
'NextFileHasLongPath',
|
||||
'NextFileHasLongLinkpath',
|
||||
'GlobalExtendedHeader',
|
||||
'ExtendedHeader',
|
||||
'OldGnuLongPath',
|
||||
'OldGnuLongLink'
|
||||
])
|
||||
|
||||
/**
|
||||
* Characters that signal a glob portion of a declared cache path. Anything to
|
||||
* the right of the first segment containing one of these is stripped when
|
||||
* deriving the longest non-glob prefix.
|
||||
*/
|
||||
const GLOB_CHAR_REGEX = /[*?[\]{}!]/
|
||||
|
||||
/**
|
||||
* Returns the working directory used for cache extraction. Mirrors the value
|
||||
* used by `tar.ts` so validation matches the actual extraction CWD.
|
||||
*/
|
||||
export function getWorkingDirectory(): string {
|
||||
return process.env['GITHUB_WORKSPACE'] ?? process.cwd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the set of allowed extraction roots from the user-declared cache
|
||||
* `paths` input.
|
||||
*
|
||||
* For each declared path:
|
||||
* - The longest leading prefix that contains no glob metacharacters is taken.
|
||||
* - `~` and `~/...` are expanded to the user's home directory.
|
||||
* - `$VAR`, `${VAR}` (POSIX) and `%VAR%` (Windows) environment references
|
||||
* are expanded.
|
||||
* - Patterns starting with `!` (glob negation) are dropped — negations narrow
|
||||
* what gets cached, not what extraction is allowed to write.
|
||||
* - The result is resolved to an absolute path against `extractCwd` (or
|
||||
* `getWorkingDirectory()` if not provided).
|
||||
*
|
||||
* The final list is deduplicated and parents subsume their children — if
|
||||
* `/a` is allowed, `/a/b` is dropped from the list.
|
||||
*
|
||||
* No filesystem canonicalization (`realpath`) is performed: we honor what
|
||||
* the user wrote literally. This preserves the user's intent if they
|
||||
* deliberately included a directory that happens to be a symlink.
|
||||
*/
|
||||
export function deriveAllowedRoots(
|
||||
declaredPaths: string[],
|
||||
extractCwd: string = getWorkingDirectory()
|
||||
): string[] {
|
||||
const roots: string[] = []
|
||||
for (const raw of declaredPaths) {
|
||||
if (raw === undefined || raw === null) continue
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') continue
|
||||
if (trimmed.startsWith('!')) continue
|
||||
const root = deriveRoot(trimmed, extractCwd)
|
||||
if (root !== undefined) roots.push(root)
|
||||
}
|
||||
return collapseRoots(roots)
|
||||
}
|
||||
|
||||
function deriveRoot(declaredPath: string, extractCwd: string): string {
|
||||
// Tilde expansion is structural (only valid at the very start) and cannot
|
||||
// introduce glob characters, so apply it up-front.
|
||||
let raw = declaredPath
|
||||
if (raw === '~') {
|
||||
raw = os.homedir()
|
||||
} else if (raw.startsWith('~/') || raw.startsWith('~\\')) {
|
||||
raw = path.join(os.homedir(), raw.slice(2))
|
||||
}
|
||||
|
||||
// Identify the longest leading run of segments that contain no glob
|
||||
// metacharacters BEFORE expanding env-var references. Doing so handles
|
||||
// two distinct concerns:
|
||||
// 1. `${VAR}` reference syntax contains `{` and `}`, which would
|
||||
// otherwise match the brace-glob class and discard the entire
|
||||
// reference from the prefix.
|
||||
// 2. If an env value happens to contain a glob character (rare, but
|
||||
// possible on attacker-influenced env), expanding before checking
|
||||
// would shorten the prefix mid-path and silently broaden the
|
||||
// allowed root. By checking the raw text and only expanding the
|
||||
// kept prefix afterwards, env-derived characters are preserved
|
||||
// verbatim in the resulting root rather than truncating it.
|
||||
// Split on either separator so glob detection works for both styles;
|
||||
// a leading empty segment from absolute POSIX paths (e.g. "/a/b" →
|
||||
// ["", "a", "b"]) is preserved so the rejoin below produces "/a/b".
|
||||
const segments = raw.split(/[\\/]/)
|
||||
const nonGlobSegments: string[] = []
|
||||
for (const seg of segments) {
|
||||
if (segmentHasGlob(seg)) break
|
||||
nonGlobSegments.push(seg)
|
||||
}
|
||||
|
||||
// Expand env vars only on the kept prefix.
|
||||
const prefix = expandEnvVars(nonGlobSegments.join('/'))
|
||||
|
||||
// If the pattern starts with a glob metachar (e.g. "**/foo"), the prefix
|
||||
// is empty — fall back to the extraction CWD.
|
||||
if (prefix === '' || prefix === '.') {
|
||||
return path.resolve(extractCwd)
|
||||
}
|
||||
|
||||
// Resolve relative to the extraction CWD; this produces an absolute path.
|
||||
return path.resolve(extractCwd, prefix)
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `seg` contains a glob metacharacter that isn't part of an
|
||||
* env-var reference. Strips `${VAR}`, `$VAR`, and `%VAR%` first so the
|
||||
* curly braces in `${VAR}` aren't misread as a brace-glob.
|
||||
*/
|
||||
function segmentHasGlob(seg: string): boolean {
|
||||
const stripped = seg
|
||||
.replace(/\$\{[^}]+\}/g, '')
|
||||
.replace(/\$[A-Za-z_][A-Za-z0-9_]*/g, '')
|
||||
.replace(/%[^%]+%/g, '')
|
||||
return GLOB_CHAR_REGEX.test(stripped)
|
||||
}
|
||||
|
||||
function expandEnvVars(input: string): string {
|
||||
let result = input
|
||||
// ${VAR}
|
||||
result = result.replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] ?? '')
|
||||
// $VAR (POSIX-style identifier)
|
||||
result = result.replace(
|
||||
/\$([A-Za-z_][A-Za-z0-9_]*)/g,
|
||||
(_, name) => process.env[name] ?? ''
|
||||
)
|
||||
// %VAR% (Windows-style)
|
||||
result = result.replace(/%([^%]+)%/g, (_, name) => process.env[name] ?? '')
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate roots and drop any root that is contained within another root
|
||||
* already in the set. Comparison is segment-aware so `/aa` is NOT considered
|
||||
* a child of `/a`.
|
||||
*/
|
||||
function collapseRoots(roots: string[]): string[] {
|
||||
const normalized: string[] = []
|
||||
for (const r of roots) {
|
||||
const n = path.normalize(r)
|
||||
if (!normalized.some(existing => pathsEqual(existing, n))) {
|
||||
normalized.push(n)
|
||||
}
|
||||
}
|
||||
// Sort so shorter (potentially-parent) paths come first.
|
||||
normalized.sort((a, b) => a.length - b.length)
|
||||
const result: string[] = []
|
||||
for (const candidate of normalized) {
|
||||
if (!result.some(parent => isUnderRoot(candidate, parent))) {
|
||||
result.push(candidate)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single archive entry against the allowed roots.
|
||||
*
|
||||
* For symlinks/hardlinks the link target is also validated:
|
||||
* - For symlinks, the target is resolved relative to the entry's directory
|
||||
* (matching POSIX symlink semantics).
|
||||
* - For hardlinks, the target is resolved relative to the extraction CWD
|
||||
* (matching tar's hardlink-target semantics).
|
||||
*
|
||||
* The validator never touches the filesystem; it operates purely on path
|
||||
* strings. Allowed roots are not realpath'd — the caller is expected to
|
||||
* derive them via {@link deriveAllowedRoots} which honors the user's
|
||||
* declared paths literally.
|
||||
*/
|
||||
export function validateEntry(
|
||||
entryPath: string,
|
||||
linkPath: string | undefined,
|
||||
entryType: string,
|
||||
allowedRoots: string[] | PreparedAllowedRoots,
|
||||
extractCwd: string = getWorkingDirectory()
|
||||
): ValidationResult {
|
||||
// Accept either a raw string[] (kept for callers / unit tests) or a
|
||||
// pre-computed PreparedAllowedRoots produced by prepareAllowedRoots. The
|
||||
// hot path (listAndValidate) always passes the prepared form.
|
||||
const prepared = Array.isArray(allowedRoots)
|
||||
? prepareAllowedRoots(allowedRoots)
|
||||
: allowedRoots
|
||||
// Skip header-only entries — they describe the next real entry.
|
||||
if (HEADER_ONLY_ENTRY_TYPES.has(entryType)) {
|
||||
return {ok: true}
|
||||
}
|
||||
|
||||
// Compute the resolved entry path up-front so every failure branch can
|
||||
// report it. `path.resolve` is pure string manipulation and is safe to
|
||||
// call even for inputs that we will subsequently reject (NUL bytes,
|
||||
// UNC paths, absolute paths). For absolute inputs the cwd component is
|
||||
// discarded by path.resolve, which is the behavior we want — the
|
||||
// reported `resolved` is the absolute location the entry would write to.
|
||||
const resolvedEntry = resolveEntry(entryPath, extractCwd)
|
||||
|
||||
// Reject anything that is not on the allow-list of known-safe entry types.
|
||||
// This rejects device/FIFO/sparse entries, and also rejects any future or
|
||||
// attacker-supplied typeflag byte that node-tar surfaces as an unknown
|
||||
// string (since a cache archive should never legitimately contain one).
|
||||
if (!ALLOWED_ENTRY_TYPES.has(entryType)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'UNSUPPORTED_TYPE',
|
||||
resolved: resolvedEntry,
|
||||
reason: `unsupported tar entry type: ${entryType}`
|
||||
}
|
||||
}
|
||||
|
||||
// Reject NUL bytes anywhere in the path.
|
||||
if (entryPath.includes('\0')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NUL_BYTE',
|
||||
resolved: resolvedEntry,
|
||||
reason: 'NUL byte in entry path'
|
||||
}
|
||||
}
|
||||
|
||||
// Reject UNC paths. Check the original string before separator normalization
|
||||
// because UNC is identified by leading `\\` or `//`.
|
||||
if (
|
||||
entryPath.startsWith('\\\\') ||
|
||||
entryPath.startsWith('//') ||
|
||||
/^[\\/]{2}\?[\\/]/.test(entryPath)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'UNC_PATH',
|
||||
resolved: resolvedEntry,
|
||||
reason: `UNC path not allowed: ${entryPath}`
|
||||
}
|
||||
}
|
||||
|
||||
// Reject Windows-style absolute paths and drive-relative paths (`C:foo`).
|
||||
// These can be present in archives created on Windows even when extracted
|
||||
// on POSIX, so we reject them on every platform.
|
||||
if (/^[a-zA-Z]:/.test(entryPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'ABSOLUTE_PATH',
|
||||
resolved: resolvedEntry,
|
||||
reason: `absolute or drive-relative path not allowed: ${entryPath}`
|
||||
}
|
||||
}
|
||||
|
||||
// Reject POSIX absolute paths.
|
||||
if (entryPath.startsWith('/') || entryPath.startsWith('\\')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'ABSOLUTE_PATH',
|
||||
resolved: resolvedEntry,
|
||||
reason: `absolute path not allowed: ${entryPath}`
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUnderAnyPreparedRoot(resolvedEntry, prepared)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'OUTSIDE_ROOTS',
|
||||
resolved: resolvedEntry,
|
||||
reason: `path resolves outside allowed roots: ${entryPath} -> ${resolvedEntry}`
|
||||
}
|
||||
}
|
||||
|
||||
// Validate link targets for symlinks and hardlinks.
|
||||
if (
|
||||
linkPath !== undefined &&
|
||||
linkPath !== '' &&
|
||||
(entryType === 'SymbolicLink' || entryType === 'Link')
|
||||
) {
|
||||
let resolvedLink: string
|
||||
if (entryType === 'SymbolicLink') {
|
||||
// Symlink targets are resolved relative to the link's containing
|
||||
// directory at extraction time. We mirror that here so a symlink
|
||||
// pointing outside the allowed roots is rejected.
|
||||
resolvedLink = path.resolve(path.dirname(resolvedEntry), linkPath)
|
||||
} else {
|
||||
// Hardlink targets are resolved relative to the extraction CWD.
|
||||
resolvedLink = path.resolve(extractCwd, linkPath)
|
||||
}
|
||||
if (linkPath.includes('\0')) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NUL_BYTE',
|
||||
resolved: resolvedLink,
|
||||
reason: 'NUL byte in link target'
|
||||
}
|
||||
}
|
||||
if (!isUnderAnyPreparedRoot(resolvedLink, prepared)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'LINK_OUTSIDE_ROOTS',
|
||||
resolved: resolvedLink,
|
||||
reason: `link target resolves outside allowed roots: ${linkPath} -> ${resolvedLink}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {ok: true}
|
||||
}
|
||||
|
||||
function resolveEntry(entryPath: string, extractCwd: string): string {
|
||||
// Tar paths are POSIX-style. Convert to native separators so path.resolve
|
||||
// produces the right thing on Windows.
|
||||
const native = entryPath.split(/[\\/]/).join(path.sep)
|
||||
return path.resolve(extractCwd, native)
|
||||
}
|
||||
|
||||
function isUnderAnyRoot(resolved: string, roots: string[]): boolean {
|
||||
for (const root of roots) {
|
||||
if (isUnderRoot(resolved, root)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Precompute a {@link PreparedAllowedRoots} from a raw roots array. Call
|
||||
* once per `listAndValidate` invocation and reuse for every entry.
|
||||
*
|
||||
* Pulling these constants out of the inner loop is the difference between
|
||||
* O(entries × roots) `path.normalize`/`toLowerCase` calls and O(entries +
|
||||
* roots) calls — for large caches the per-entry validation reduces to a
|
||||
* single normalize + (optional) lowercase plus N pre-baked string compares.
|
||||
*/
|
||||
export function prepareAllowedRoots(roots: string[]): PreparedAllowedRoots {
|
||||
const caseInsensitive = caseInsensitiveFs()
|
||||
const prepared: PreparedRoot[] = roots.map(root => {
|
||||
const normParent = path.normalize(root)
|
||||
const parentWithSep = normParent.endsWith(path.sep)
|
||||
? normParent
|
||||
: normParent + path.sep
|
||||
return {
|
||||
normParent,
|
||||
normParentCmp: caseInsensitive ? normParent.toLowerCase() : normParent,
|
||||
parentWithSepCmp: caseInsensitive
|
||||
? parentWithSep.toLowerCase()
|
||||
: parentWithSep
|
||||
}
|
||||
})
|
||||
return {caseInsensitive, roots: prepared}
|
||||
}
|
||||
|
||||
function isUnderAnyPreparedRoot(
|
||||
resolved: string,
|
||||
prepared: PreparedAllowedRoots
|
||||
): boolean {
|
||||
const normChild = path.normalize(resolved)
|
||||
const childCmp = prepared.caseInsensitive
|
||||
? normChild.toLowerCase()
|
||||
: normChild
|
||||
for (const root of prepared.roots) {
|
||||
if (childCmp === root.normParentCmp) return true
|
||||
if (childCmp.startsWith(root.parentWithSepCmp)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isUnderRoot(child: string, parent: string): boolean {
|
||||
const normChild = path.normalize(child)
|
||||
const normParent = path.normalize(parent)
|
||||
if (pathsEqual(normChild, normParent)) return true
|
||||
const parentWithSep = normParent.endsWith(path.sep)
|
||||
? normParent
|
||||
: normParent + path.sep
|
||||
if (caseInsensitiveFs()) {
|
||||
return normChild.toLowerCase().startsWith(parentWithSep.toLowerCase())
|
||||
}
|
||||
return normChild.startsWith(parentWithSep)
|
||||
}
|
||||
|
||||
function pathsEqual(a: string, b: string): boolean {
|
||||
return caseInsensitiveFs() ? a.toLowerCase() === b.toLowerCase() : a === b
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservative platform-level guess at filesystem case-sensitivity. Used by
|
||||
* {@link isUnderRoot} and {@link pathsEqual} when comparing allowed roots
|
||||
* against resolved entry paths.
|
||||
*
|
||||
* - Windows: NTFS is case-insensitive by default (per-directory case
|
||||
* sensitivity via `fsutil` is opt-in and rare).
|
||||
* - macOS: APFS and HFS+ default to case-insensitive; the case-sensitive
|
||||
* APFS variant is selectable at format time but rarely used outside of
|
||||
* developer setups.
|
||||
* - Other platforms (Linux/BSD/etc.) are assumed case-sensitive.
|
||||
*
|
||||
* On a case-sensitive volume mounted under a case-insensitive platform
|
||||
* (e.g. case-sensitive APFS on macOS) this guess errs toward treating the
|
||||
* fs as case-insensitive, which **loosens** the comparison: an entry like
|
||||
* `cache/Foo` could be considered to be under the allowed root
|
||||
* `/workspace/cache/foo` even though the underlying fs would create them
|
||||
* as distinct paths. The direction of the error is fewer violations
|
||||
* reported, not more, so this is a soundness loosening rather than a
|
||||
* security-relevant miss — the worst outcome is letting an entry through
|
||||
* that the actual extraction would then write to a different path than
|
||||
* the allowed root suggests.
|
||||
*
|
||||
* Probing the workspace with `fs.realpathSync.native` would be more
|
||||
* accurate but adds I/O on every restore and complicates the otherwise
|
||||
* pure path-string validator, so this platform-level heuristic is
|
||||
* preferred for now. Revisit if real-world reports show false negatives
|
||||
* on case-sensitive macOS/Windows volumes.
|
||||
*/
|
||||
function caseInsensitiveFs(): boolean {
|
||||
return process.platform === 'win32' || process.platform === 'darwin'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a violation list for human consumption in a single warning message.
|
||||
* Truncates after `maxShown` entries and appends a count of the remainder so
|
||||
* the warning stays a reasonable size.
|
||||
*/
|
||||
export function formatViolationSummary(
|
||||
violations: PathValidationViolation[],
|
||||
maxShown = 5
|
||||
): string {
|
||||
const total = violations.length
|
||||
if (total === 0) return ''
|
||||
const shown = violations.slice(0, maxShown)
|
||||
const lines = shown.map(v => ` - ${v.reason}`)
|
||||
const remainder = total - shown.length
|
||||
if (remainder > 0) {
|
||||
lines.push(` - ...and ${remainder} more (see debug log for full list)`)
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
Vendored
+103
-2
@@ -1,4 +1,5 @@
|
||||
import {exec} from '@actions/exec'
|
||||
import * as core from '@actions/core'
|
||||
import * as io from '@actions/io'
|
||||
import {existsSync, writeFileSync} from 'fs'
|
||||
import * as path from 'path'
|
||||
@@ -11,6 +12,14 @@ import {
|
||||
TarFilename,
|
||||
ManifestFilename
|
||||
} from './constants.js'
|
||||
import {CacheIntegrityError} from './cacheIntegrityError.js'
|
||||
import {listAndValidate} from './listAndValidate.js'
|
||||
import {
|
||||
PathValidationMode,
|
||||
PathValidationViolation,
|
||||
deriveAllowedRoots,
|
||||
formatViolationSummary
|
||||
} from './pathValidation.js'
|
||||
|
||||
const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
@@ -272,15 +281,107 @@ export async function listTar(
|
||||
// Extract a tar
|
||||
export async function extractTar(
|
||||
archivePath: string,
|
||||
compressionMethod: CompressionMethod
|
||||
compressionMethod: CompressionMethod,
|
||||
options?: {
|
||||
declaredPaths?: string[]
|
||||
pathValidation?: PathValidationMode
|
||||
}
|
||||
): Promise<void> {
|
||||
// Create directory to extract tar into
|
||||
const workingDirectory = getWorkingDirectory()
|
||||
const pathValidation: PathValidationMode = options?.pathValidation ?? 'off'
|
||||
|
||||
// Run path validation BEFORE creating the extraction directory or invoking
|
||||
// system tar. In 'error' mode, a CacheIntegrityError thrown here means no
|
||||
// bytes are ever written to the workspace. In 'warn' mode, violations are
|
||||
// logged and extraction proceeds.
|
||||
if (pathValidation !== 'off') {
|
||||
const declaredPaths = options?.declaredPaths ?? []
|
||||
let allowedRoots = deriveAllowedRoots(declaredPaths, workingDirectory)
|
||||
// Fail-safe: if the caller didn't supply any declared paths (or all
|
||||
// supplied paths were empty/negations), fall back to the workspace
|
||||
// root as the sole allowed root. This still catches archives that try
|
||||
// to escape the workspace via `..` or absolute paths.
|
||||
if (allowedRoots.length === 0) {
|
||||
allowedRoots = [workingDirectory]
|
||||
}
|
||||
let violations: PathValidationViolation[] | undefined
|
||||
try {
|
||||
violations = await listAndValidate(
|
||||
archivePath,
|
||||
compressionMethod,
|
||||
allowedRoots,
|
||||
workingDirectory
|
||||
)
|
||||
} catch (error) {
|
||||
// Parse / decompression failure encountered while validating. The
|
||||
// validator's tar parser is stricter than the system `tar` that
|
||||
// performs the actual extraction, so an archive can fail validation
|
||||
// here yet still extract cleanly. Honor the caller's mode:
|
||||
// - 'error': hard-fail; do not extract.
|
||||
// - 'warn': log a warning, skip validation, and let system tar
|
||||
// have a go. This preserves the legacy behavior where a
|
||||
// quirky-but-extractable archive doesn't break the build
|
||||
// just because the user opted into validation.
|
||||
const message = `Cache archive integrity check failed: ${
|
||||
(error as Error).message
|
||||
}`
|
||||
if (pathValidation === 'error') {
|
||||
throw new CacheIntegrityError('PARSE_ERROR', message)
|
||||
}
|
||||
core.warning(
|
||||
`${message}\nSkipping path validation and proceeding with extraction because pathValidation is 'warn'.`
|
||||
)
|
||||
}
|
||||
if (violations && violations.length > 0) {
|
||||
reportViolations(violations, pathValidation)
|
||||
if (pathValidation === 'error') {
|
||||
throw new CacheIntegrityError(
|
||||
'PATH_VIOLATION',
|
||||
`Cache archive contains ${violations.length} entr${
|
||||
violations.length === 1 ? 'y' : 'ies'
|
||||
} that resolve outside the declared cache paths. ` +
|
||||
`Refusing to extract because pathValidation is 'error'.`,
|
||||
violations
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory to extract tar into
|
||||
await io.mkdirP(workingDirectory)
|
||||
const commands = await getCommands(compressionMethod, 'extract', archivePath)
|
||||
await execCommands(commands)
|
||||
}
|
||||
|
||||
function reportViolations(
|
||||
violations: PathValidationViolation[],
|
||||
mode: PathValidationMode
|
||||
): void {
|
||||
const header =
|
||||
mode === 'error'
|
||||
? `Cache archive failed integrity check (${violations.length} violation${
|
||||
violations.length === 1 ? '' : 's'
|
||||
}).`
|
||||
: `Cache archive contains ${violations.length} entr${
|
||||
violations.length === 1 ? 'y' : 'ies'
|
||||
} that resolve outside the declared cache paths.`
|
||||
// One-line warning so the Actions log surfaces a single attention-grabbing
|
||||
// entry. The truncated, human-readable list goes to `core.info` so users see
|
||||
// it at default verbosity without us emitting multi-line warnings (which
|
||||
// some log surfaces collapse). Full per-violation details still go to
|
||||
// `core.debug` for triage of large archives.
|
||||
core.warning(header)
|
||||
core.info(formatViolationSummary(violations))
|
||||
for (const v of violations) {
|
||||
core.debug(
|
||||
`path-validation: code=${v.code} type=${v.entryType} path=${v.path}` +
|
||||
(v.linkpath ? ` linkpath=${v.linkpath}` : '') +
|
||||
` resolved=${v.resolved}` +
|
||||
` reason=${v.reason}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a tar
|
||||
export async function createTar(
|
||||
archiveFolder: string,
|
||||
|
||||
Vendored
+37
-1
@@ -80,6 +80,32 @@ export interface DownloadOptions {
|
||||
* @default false
|
||||
*/
|
||||
lookupOnly?: boolean
|
||||
|
||||
/**
|
||||
* Whether to validate that every entry in the downloaded cache archive
|
||||
* resolves to a path under one of the declared cache `paths` before
|
||||
* extraction. Symlink and hardlink targets are also validated.
|
||||
*
|
||||
* - 'off' (default): no validation, identical to legacy behavior.
|
||||
* - 'warn': validate before extraction; log a single aggregated
|
||||
* `core.warning()` summary (with full per-entry detail in
|
||||
* `core.debug()`) if violations are found, then extract anyway.
|
||||
* - 'error': validate before extraction; throw `CacheIntegrityError`
|
||||
* (with `code: 'PATH_VIOLATION'`) on any violation. Extraction
|
||||
* does not run when this throws.
|
||||
*
|
||||
* Parse / decompression failures encountered while validating are
|
||||
* handled per-mode:
|
||||
* - 'warn': logged via `core.warning()`; validation is skipped and
|
||||
* extraction proceeds (the system `tar` invoked for the actual
|
||||
* extraction is more lenient than the validator and may still
|
||||
* succeed).
|
||||
* - 'error': surfaced as `CacheIntegrityError` with `code: 'PARSE_ERROR'`
|
||||
* and extraction does not run.
|
||||
*
|
||||
* @default 'off'
|
||||
*/
|
||||
pathValidation?: 'off' | 'warn' | 'error'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +173,8 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
|
||||
downloadConcurrency: 8,
|
||||
timeoutInMs: 30000,
|
||||
segmentTimeoutInMs: 600000,
|
||||
lookupOnly: false
|
||||
lookupOnly: false,
|
||||
pathValidation: 'off'
|
||||
}
|
||||
|
||||
if (copy) {
|
||||
@@ -174,6 +201,14 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
|
||||
if (typeof copy.lookupOnly === 'boolean') {
|
||||
result.lookupOnly = copy.lookupOnly
|
||||
}
|
||||
|
||||
if (
|
||||
copy.pathValidation === 'off' ||
|
||||
copy.pathValidation === 'warn' ||
|
||||
copy.pathValidation === 'error'
|
||||
) {
|
||||
result.pathValidation = copy.pathValidation
|
||||
}
|
||||
}
|
||||
const segmentDownloadTimeoutMins =
|
||||
process.env['SEGMENT_DOWNLOAD_TIMEOUT_MINS']
|
||||
@@ -193,6 +228,7 @@ export function getDownloadOptions(copy?: DownloadOptions): DownloadOptions {
|
||||
)
|
||||
core.debug(`Segment download timeout (ms): ${result.segmentTimeoutInMs}`)
|
||||
core.debug(`Lookup only: ${result.lookupOnly}`)
|
||||
core.debug(`Path validation: ${result.pathValidation}`)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user