mirror of
https://github.com/actions/toolkit.git
synced 2026-08-08 00:00:33 +02:00
- Added a new test suite for parser-differential bypass detection in tar archives. - Introduced length-correct PAX extended-header re-parsing to catch discrepancies between node-tar and system tar. - Enhanced the listAndValidate function to return approved names alongside violations for better extraction control. - Implemented checks for unsafe characters and glob metacharacters in entry paths. - Updated the tar extraction logic to utilize an allow-list for approved entries when path validation is in error mode. - Added utility functions for writing temporary allow-list files for system tar extraction.
404 lines
12 KiB
TypeScript
404 lines
12 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. These build the F1 / F2 /
|
|
* F2-linkpath / F3 / F5 PoC archives from the security analysis as raw tar
|
|
* bytes (so we can craft malicious PAX bodies and typeflags that node-tar's
|
|
* Header encoder would never produce) and assert the validator now refuses
|
|
* each one. See docs/zip-slip-* for the analysis.
|
|
*/
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PoC archives
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// F1 — unknown typeflag byte ('Z') is emitted by node-tar as an ignoredEntry.
|
|
const F1 = Buffer.concat([
|
|
fileEntry('cache/safe.txt', 'ok'),
|
|
fileEntry('../../../../../../tmp/zip_slip_F1', 'F1 pwned', 'Z'),
|
|
end()
|
|
])
|
|
|
|
// F2 — PAX `path=` newline differential.
|
|
const F2 = Buffer.concat([
|
|
paxEntry(
|
|
Buffer.from(
|
|
'42 path=../../../../../../tmp/zip_slip_F2\n30 comment=x\n17 path=safe.txt\n',
|
|
'ascii'
|
|
)
|
|
),
|
|
fileEntry('cache/safe.txt', 'F2 pwned'),
|
|
end()
|
|
])
|
|
|
|
// F2-linkpath — same differential, applied to a symlink's `linkpath=`.
|
|
const F2L = 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()
|
|
])
|
|
|
|
// F3 — oversized PAX header (> 1 MiB) is dropped by node-tar's
|
|
// maxMetaEntrySize and would otherwise let a `path=` override slip through.
|
|
const F3 = Buffer.concat([
|
|
paxEntry(
|
|
Buffer.concat([
|
|
Buffer.from(`1048600 comment=${'A'.repeat(1048600 - 17)}\n`, 'ascii'),
|
|
Buffer.from('42 path=../../../../../../tmp/zip_slip_F3\n', 'ascii')
|
|
])
|
|
),
|
|
fileEntry('cache/safe.txt', 'F3 pwned'),
|
|
end()
|
|
])
|
|
|
|
// F5 — sparse typeflag 'S' is mapped but ignored by node-tar's ReadEntry.
|
|
const F5 = Buffer.concat([
|
|
fileEntry('cache/decoy.txt', 'ok'),
|
|
fileEntry('../../../../../../tmp/zip_slip_F5', '', '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('F1: unknown typeflag is rejected as UNSUPPORTED_TYPE', async () => {
|
|
const {violations, approvedNames} = await validate(F1, 'f1.tar.gz')
|
|
expect(violations).toContain('UNSUPPORTED_TYPE')
|
|
// The escaping entry must NOT be approved for extraction.
|
|
expect(approvedNames).not.toContain('../../../../../../tmp/zip_slip_F1')
|
|
})
|
|
|
|
test('F2: PAX path newline differential is rejected as PAX_DESYNC', async () => {
|
|
const {violations, approvedNames} = await validate(F2, 'f2.tar.gz')
|
|
expect(violations).toContain('PAX_DESYNC')
|
|
expect(approvedNames).toEqual([])
|
|
})
|
|
|
|
test('F2-linkpath: PAX linkpath newline differential is rejected as PAX_DESYNC', async () => {
|
|
const {violations} = await validate(F2L, 'f2l.tar.gz')
|
|
expect(violations).toContain('PAX_DESYNC')
|
|
})
|
|
|
|
test('F3: oversized PAX header is rejected as UNSUPPORTED_TYPE', async () => {
|
|
const {violations} = await validate(F3, 'f3.tar.gz')
|
|
expect(violations).toContain('UNSUPPORTED_TYPE')
|
|
})
|
|
|
|
test('F5: sparse typeflag is rejected as UNSUPPORTED_TYPE', async () => {
|
|
const {violations, approvedNames} = await validate(F5, 'f5.tar.gz')
|
|
expect(violations).toContain('UNSUPPORTED_TYPE')
|
|
expect(approvedNames).not.toContain('../../../../../../tmp/zip_slip_F5')
|
|
})
|
|
|
|
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 UNSAFE_CHAR', 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('UNSAFE_CHAR')
|
|
})
|
|
|
|
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('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, F2 archive: throws and writes nothing to the workspace', async () => {
|
|
const dest = mkdtempSync(path.join(ROOT, 'extract-f2-'))
|
|
process.env['GITHUB_WORKSPACE'] = dest
|
|
const archivePath = path.join(dest, 'f2.tar.gz')
|
|
mkdirSync(dest, {recursive: true})
|
|
writeFileSync(archivePath, gzipSync(F2))
|
|
|
|
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)
|
|
})
|
|
})
|