Files
toolkit/packages/cache/__tests__/tarPathValidationAttacks.test.ts
T

500 lines
16 KiB
TypeScript

import {
mkdtempSync,
writeFileSync,
rmSync,
mkdirSync,
existsSync,
readFileSync
} from 'fs'
import * as os from 'os'
import * as path from 'path'
import {gzipSync} from 'zlib'
import {execSync} from 'child_process'
import {CompressionMethod} from '../src/internal/constants'
import {listAndValidate} from '../src/internal/listAndValidate'
import {extractTar} from '../src/internal/tar'
import {CacheIntegrityError} from '../src/internal/cacheIntegrityError'
/**
* Parser-differential bypass regression tests. Each case builds a malicious
* archive as raw tar bytes (so we can craft PAX bodies and typeflags that
* node-tar's Header encoder would never produce) designed to make node-tar's
* in-process listing disagree with the path the system `tar` extractor would
* write to, then asserts the validator refuses it. The vectors covered are: an
* unknown typeflag byte, a PAX `path=` / `linkpath=` record with an embedded
* newline, an oversized PAX header, and a GNU sparse typeflag.
*/
// ---------------------------------------------------------------------------
// Raw tar construction
// ---------------------------------------------------------------------------
const BLOCK = 512
function octal(n: number, len: number): Buffer {
return Buffer.from(`${n.toString(8).padStart(len - 1, '0')}\0`, 'ascii')
}
function put(
buf: Buffer,
offset: number,
data: string | Buffer,
max: number
): void {
const b = Buffer.isBuffer(data) ? data : Buffer.from(data, 'ascii')
b.copy(buf, offset, 0, Math.min(b.length, max))
}
function header(opts: {
name?: string
mode?: number
size?: number
typeflag?: string
linkname?: string
}): Buffer {
const {
name = '',
mode = 0o644,
size = 0,
typeflag = '0',
linkname = ''
} = opts
const h = Buffer.alloc(BLOCK)
put(h, 0, name, 100)
octal(mode, 8).copy(h, 100)
octal(0, 8).copy(h, 108)
octal(0, 8).copy(h, 116)
octal(size, 12).copy(h, 124)
octal(0, 12).copy(h, 136)
h.fill(0x20, 148, 156) // chksum field = spaces while summing
put(h, 156, typeflag, 1)
put(h, 157, linkname, 100)
put(h, 257, 'ustar\0', 6)
put(h, 263, '00', 2)
let sum = 0
for (let i = 0; i < BLOCK; i++) sum += h[i]
put(h, 148, `${sum.toString(8).padStart(6, '0')}\0 `, 8)
return h
}
function pad(buf: Buffer): Buffer {
const p = (BLOCK - (buf.length % BLOCK)) % BLOCK
return p > 0 ? Buffer.concat([buf, Buffer.alloc(p)]) : buf
}
function fileEntry(name: string, contents: string, typeflag = '0'): Buffer {
const body = Buffer.from(contents, 'ascii')
return Buffer.concat([header({name, size: body.length, typeflag}), pad(body)])
}
function dirEntry(name: string): Buffer {
return header({
name: name.endsWith('/') ? name : `${name}/`,
mode: 0o755,
typeflag: '5'
})
}
function paxEntry(body: Buffer): Buffer {
return Buffer.concat([
header({name: 'PaxHeader', size: body.length, typeflag: 'x'}),
pad(body)
])
}
function end(): Buffer {
return Buffer.alloc(BLOCK * 2)
}
/** Build a single length-correct PAX record (`"<len> <key>=<value>\n"`). */
function paxRecord(content: string): string {
const base = 1 + Buffer.byteLength(content) + 1
let len = base + String(base).length
if (String(len).length !== String(base).length) {
len = base + String(len).length
}
return `${len} ${content}\n`
}
// ---------------------------------------------------------------------------
// Malicious archives
// ---------------------------------------------------------------------------
// Unknown typeflag byte ('Z') is emitted by node-tar as an ignoredEntry, but
// system tar would extract it as a regular file.
const unknownTypeflagArchive = Buffer.concat([
fileEntry('cache/safe.txt', 'ok'),
fileEntry('../../../../../../tmp/escaped_unknown_type', 'pwned', 'Z'),
end()
])
// PAX `path=` record whose value carries an embedded newline. node-tar's naive
// `split('\n')` parse resolves the trailing `path=safe.txt`, while a
// length-correct parse (matching system tar) resolves the escaping path.
const paxPathNewlineArchive = Buffer.concat([
paxEntry(
Buffer.from(
'42 path=../../../../../../tmp/escaped_pax\n30 comment=x\n17 path=safe.txt\n',
'ascii'
)
),
fileEntry('cache/safe.txt', 'pwned'),
end()
])
// The same embedded-newline differential applied to a symlink's `linkpath=`.
const paxLinkpathNewlineArchive = Buffer.concat([
paxEntry(
Buffer.from(
'34 linkpath=../../../../../../tmp\n37 comment=x\n24 linkpath=safe/target\n',
'ascii'
)
),
header({name: 'cache/link', typeflag: '2', linkname: 'safe/target'}),
end()
])
// Oversized PAX header (> 1 MiB) is dropped by node-tar's maxMetaEntrySize and
// would otherwise let the `path=` override slip through unseen.
const oversizedPaxHeaderArchive = Buffer.concat([
paxEntry(
Buffer.concat([
Buffer.from(`1048600 comment=${'A'.repeat(1048600 - 17)}\n`, 'ascii'),
Buffer.from('42 path=../../../../../../tmp/escaped_big\n', 'ascii')
])
),
fileEntry('cache/safe.txt', 'pwned'),
end()
])
// GNU sparse typeflag 'S' is mapped but ignored by node-tar's ReadEntry, while
// system tar would extract it.
const sparseTypeflagArchive = Buffer.concat([
fileEntry('cache/decoy.txt', 'ok'),
fileEntry('../../../../../../tmp/escaped_sparse', '', 'S'),
end()
])
// ---------------------------------------------------------------------------
// Test harness
// ---------------------------------------------------------------------------
const ROOT = mkdtempSync(path.join(os.tmpdir(), 'cache-attacks-'))
function workspace(): string {
return path.join(ROOT, 'workspace')
}
function writeGz(name: string, archive: Buffer): string {
mkdirSync(ROOT, {recursive: true})
const p = path.join(ROOT, name)
writeFileSync(p, gzipSync(archive))
return p
}
async function validate(
archive: Buffer,
name: string
): Promise<{violations: string[]; approvedNames: string[]}> {
const p = writeGz(name, archive)
const result = await listAndValidate(
p,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
return {
violations: result.violations.map(v => v.code),
approvedNames: result.approvedNames
}
}
const TAR_AVAILABLE = ((): boolean => {
try {
execSync(process.platform === 'win32' ? 'where tar' : 'which tar', {
stdio: 'ignore'
})
return true
} catch {
return false
}
})()
const describeTar = TAR_AVAILABLE ? describe : describe.skip
beforeAll(() => {
mkdirSync(workspace(), {recursive: true})
})
afterAll(() => {
try {
rmSync(ROOT, {recursive: true, force: true})
} catch {
// best-effort
}
})
describe('listAndValidate: parser-differential bypass detection', () => {
test('unknown typeflag is rejected as UNSUPPORTED_TYPE', async () => {
const {violations, approvedNames} = await validate(
unknownTypeflagArchive,
'unknown-typeflag.tar.gz'
)
expect(violations).toContain('UNSUPPORTED_TYPE')
// The escaping entry must NOT be approved for extraction.
expect(approvedNames).not.toContain(
'../../../../../../tmp/escaped_unknown_type'
)
})
test('PAX path newline differential is rejected as PAX_DESYNC', async () => {
const {violations, approvedNames} = await validate(
paxPathNewlineArchive,
'pax-path-newline.tar.gz'
)
expect(violations).toContain('PAX_DESYNC')
expect(approvedNames).toEqual([])
})
test('PAX linkpath newline differential is rejected as PAX_DESYNC', async () => {
const {violations} = await validate(
paxLinkpathNewlineArchive,
'pax-linkpath-newline.tar.gz'
)
expect(violations).toContain('PAX_DESYNC')
})
test('oversized PAX header is rejected as UNSUPPORTED_TYPE', async () => {
const {violations} = await validate(
oversizedPaxHeaderArchive,
'oversized-pax.tar.gz'
)
expect(violations).toContain('UNSUPPORTED_TYPE')
})
test('sparse typeflag is rejected as UNSUPPORTED_TYPE', async () => {
const {violations, approvedNames} = await validate(
sparseTypeflagArchive,
'sparse-typeflag.tar.gz'
)
expect(violations).toContain('UNSUPPORTED_TYPE')
expect(approvedNames).not.toContain('../../../../../../tmp/escaped_sparse')
})
test('glob metacharacter in entry path is rejected as GLOB_METACHAR', async () => {
const archive = Buffer.concat([fileEntry('cache/[id].js', 'x'), end()])
const {violations} = await validate(archive, 'glob.tar.gz')
expect(violations).toContain('GLOB_METACHAR')
})
test('clean archive: approvedNames lists every concrete entry, no violations', async () => {
const archive = Buffer.concat([
dirEntry('cache/'),
fileEntry('cache/file.txt', 'hi'),
dirEntry('cache/sub/'),
fileEntry('cache/sub/deep.txt', 'deep'),
end()
])
const {violations, approvedNames} = await validate(archive, 'clean.tar.gz')
expect(violations).toEqual([])
expect(approvedNames).toEqual([
'cache/',
'cache/file.txt',
'cache/sub/',
'cache/sub/deep.txt'
])
})
test('newline in entry path is rejected as UNSAFE_CHAR', async () => {
const archive = Buffer.concat([fileEntry('cache/a\nb.txt', 'x'), end()])
const {violations} = await validate(archive, 'newline.tar.gz')
expect(violations).toContain('UNSAFE_CHAR')
})
test('NUL byte in a symlink target (via PAX) is rejected as NUL_BYTE', async () => {
const archive = Buffer.concat([
paxEntry(Buffer.from(paxRecord('linkpath=cache/sub/t\0'), 'ascii')),
header({name: 'cache/link', typeflag: '2', linkname: 'cache/sub/t'}),
end()
])
const {violations} = await validate(archive, 'nul-link.tar.gz')
expect(violations).toContain('NUL_BYTE')
})
test('legitimate long path via PAX: no violations, approved by its PAX path', async () => {
const longName = `cache/${'d/'.repeat(60)}file.txt`
const archive = Buffer.concat([
paxEntry(Buffer.from(paxRecord(`path=${longName}`), 'ascii')),
// ustar name is a short placeholder; the PAX `path` overrides it.
fileEntry('cache/placeholder', 'x'),
end()
])
const {violations, approvedNames} = await validate(
archive,
'longpath.tar.gz'
)
expect(violations).toEqual([])
expect(approvedNames).toContain(longName)
})
test('unknown PAX key is rejected as PAX_UNKNOWN_KEY', async () => {
const archive = Buffer.concat([
paxEntry(Buffer.from(paxRecord('EVIL.placement=1'), 'ascii')),
fileEntry('cache/x', 'y'),
end()
])
const {violations} = await validate(archive, 'unknown-key.tar.gz')
expect(violations).toContain('PAX_UNKNOWN_KEY')
})
test('PAX sparse (GNU.sparse.name) is rejected as PAX_UNSUPPORTED_KEY', async () => {
// node-tar v7 ignores GNU sparse keys, so without this rejection the entry
// would be approved under its benign header path while system tar would
// reconstruct the file at GNU.sparse.name (here outside the cache roots).
const sparseName = '../../../../../../tmp/escaped_sparse_pax'
const body = Buffer.from(
paxRecord('GNU.sparse.major=1') +
paxRecord('GNU.sparse.minor=0') +
paxRecord(`GNU.sparse.name=${sparseName}`) +
paxRecord('GNU.sparse.realsize=4'),
'ascii'
)
const archive = Buffer.concat([
paxEntry(body),
fileEntry('cache/GNUSparseFile.0/decoy', 'data'),
end()
])
const {violations, approvedNames} = await validate(archive, 'sparse.tar.gz')
expect(violations).toContain('PAX_UNSUPPORTED_KEY')
expect(approvedNames).not.toContain(sparseName)
})
test('flood of extended headers is rejected (pending-meta cap)', async () => {
const metas: Buffer[] = []
for (let i = 0; i < 70; i++) {
metas.push(paxEntry(Buffer.from(paxRecord('comment=x'), 'ascii')))
}
const archive = Buffer.concat([...metas, fileEntry('cache/x', 'y'), end()])
const p = writeGz('meta-flood.tar.gz', archive)
await expect(
listAndValidate(
p,
CompressionMethod.Gzip,
[path.join(workspace(), 'cache')],
workspace()
)
).rejects.toThrow()
})
})
describeTar('extractTar end-to-end with system tar allow-list', () => {
let savedWorkspace: string | undefined
beforeEach(() => {
savedWorkspace = process.env['GITHUB_WORKSPACE']
})
afterEach(() => {
if (savedWorkspace === undefined) {
delete process.env['GITHUB_WORKSPACE']
} else {
process.env['GITHUB_WORKSPACE'] = savedWorkspace
}
})
test('error mode, clean archive: every approved member is extracted', async () => {
const dest = mkdtempSync(path.join(ROOT, 'extract-clean-'))
process.env['GITHUB_WORKSPACE'] = dest
const archive = Buffer.concat([
dirEntry('cache/'),
fileEntry('cache/file.txt', 'hello'),
dirEntry('cache/sub/'),
fileEntry('cache/sub/deep.txt', 'deep'),
end()
])
const archivePath = path.join(dest, 'clean.tar.gz')
mkdirSync(dest, {recursive: true})
writeFileSync(archivePath, gzipSync(archive))
await extractTar(archivePath, CompressionMethod.Gzip, {
declaredPaths: ['cache/**'],
pathValidation: 'error'
})
expect(existsSync(path.join(dest, 'cache', 'file.txt'))).toBe(true)
expect(readFileSync(path.join(dest, 'cache', 'file.txt'), 'utf8')).toBe(
'hello'
)
expect(existsSync(path.join(dest, 'cache', 'sub', 'deep.txt'))).toBe(true)
})
test('error mode, PAX path newline archive: throws and writes nothing to the workspace', async () => {
const dest = mkdtempSync(path.join(ROOT, 'extract-pax-path-newline-'))
process.env['GITHUB_WORKSPACE'] = dest
const archivePath = path.join(dest, 'pax-path-newline.tar.gz')
mkdirSync(dest, {recursive: true})
writeFileSync(archivePath, gzipSync(paxPathNewlineArchive))
await expect(
extractTar(archivePath, CompressionMethod.Gzip, {
declaredPaths: ['cache/**'],
pathValidation: 'error'
})
).rejects.toThrow(CacheIntegrityError)
// No member was extracted anywhere under the workspace.
expect(existsSync(path.join(dest, 'cache'))).toBe(false)
expect(existsSync(path.join(dest, 'safe.txt'))).toBe(false)
})
test('error mode: a leading ./ entry still extracts (allow-list name matches)', async () => {
// node-tar surfaces the entry as `./cache/dotslash.txt`; the `-T` allow
// list must use the canonical `cache/dotslash.txt` so the member is not
// silently skipped. Verifies the canonicalMemberName normalization under
// whichever system tar is present (GNU on Linux CI, BSD on macOS).
const dest = mkdtempSync(path.join(ROOT, 'extract-dotslash-'))
process.env['GITHUB_WORKSPACE'] = dest
const archive = Buffer.concat([
dirEntry('cache/'),
fileEntry('./cache/dotslash.txt', 'dot'),
end()
])
const archivePath = path.join(dest, 'dotslash.tar.gz')
mkdirSync(dest, {recursive: true})
writeFileSync(archivePath, gzipSync(archive))
await extractTar(archivePath, CompressionMethod.Gzip, {
declaredPaths: ['cache/**'],
pathValidation: 'error'
})
expect(existsSync(path.join(dest, 'cache', 'dotslash.txt'))).toBe(true)
expect(readFileSync(path.join(dest, 'cache', 'dotslash.txt'), 'utf8')).toBe(
'dot'
)
})
test('error mode: a long path via PAX is extracted, not dropped by the allow-list', async () => {
// > 100 bytes, so the name travels via a PAX `path=` record (and a GNU
// long-name on creation). Exercises long-name matching in the `-T` list so
// a legitimate long path is not silently skipped during extraction.
const dest = mkdtempSync(path.join(ROOT, 'extract-long-'))
process.env['GITHUB_WORKSPACE'] = dest
const longRel = `cache/${'x'.repeat(110)}.txt`
const archive = Buffer.concat([
paxEntry(Buffer.from(paxRecord(`path=${longRel}`), 'ascii')),
fileEntry('cache/placeholder', 'L'),
end()
])
const archivePath = path.join(dest, 'long.tar.gz')
mkdirSync(dest, {recursive: true})
writeFileSync(archivePath, gzipSync(archive))
await extractTar(archivePath, CompressionMethod.Gzip, {
declaredPaths: ['cache/**'],
pathValidation: 'error'
})
expect(existsSync(path.join(dest, longRel))).toBe(true)
})
})