mirror of
https://github.com/actions/toolkit.git
synced 2026-08-12 00:00:17 +02:00
Implement PAX header validation and path safety checks in tar extraction
- 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.
This commit is contained in:
+11
-1
@@ -5,7 +5,17 @@ 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'
|
||||
import {listAndValidate as listAndValidateImpl} from '../src/internal/listAndValidate'
|
||||
|
||||
/**
|
||||
* Thin wrapper returning only the violations array, so the many existing
|
||||
* assertions below can keep treating the result as a flat list. New tests that
|
||||
* need the `approvedNames` allow-list call `listAndValidateImpl` directly.
|
||||
*/
|
||||
const listAndValidate = async (
|
||||
...args: Parameters<typeof listAndValidateImpl>
|
||||
): Promise<Awaited<ReturnType<typeof listAndValidateImpl>>['violations']> =>
|
||||
(await listAndValidateImpl(...args)).violations
|
||||
|
||||
/**
|
||||
* Real-archive integration tests for listAndValidate. These build small tar
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
parsePaxLengthCorrect,
|
||||
crossCheckMetaBodies,
|
||||
PAX_KNOWN_KEYS
|
||||
} from '../src/internal/pax-reparse'
|
||||
|
||||
/** Build a length-correct PAX record string for `<key>=<value>` content. */
|
||||
function rec(content: string): string {
|
||||
const base = 1 + Buffer.byteLength(content) + 1 // space + content + LF
|
||||
let len = base + String(base).length
|
||||
if (String(len).length !== String(base).length) {
|
||||
len = base + String(len).length
|
||||
}
|
||||
return `${len} ${content}\n`
|
||||
}
|
||||
|
||||
describe('parsePaxLengthCorrect', () => {
|
||||
test('single well-formed record', () => {
|
||||
const buf = Buffer.from('17 path=safe.txt\n', 'ascii')
|
||||
const {records, ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(true)
|
||||
expect(records['path'].toString('utf8')).toBe('safe.txt')
|
||||
})
|
||||
|
||||
test('newline-in-value: the embedded fake record is swallowed by comment', () => {
|
||||
// This is the F2 PAX body. A naive split('\n') parser (node-tar) ends up
|
||||
// with path=safe.txt; the length-correct parser must yield the real
|
||||
// (malicious) path and treat the rest as the comment value.
|
||||
const buf = Buffer.from(
|
||||
'42 path=../../../../../../tmp/zip_slip_F2\n30 comment=x\n17 path=safe.txt\n',
|
||||
'ascii'
|
||||
)
|
||||
const {records, ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(true)
|
||||
expect(records['path'].toString('utf8')).toBe(
|
||||
'../../../../../../tmp/zip_slip_F2'
|
||||
)
|
||||
expect(records['comment'].toString('utf8')).toBe('x\n17 path=safe.txt')
|
||||
// Crucially NOT safe.txt.
|
||||
expect(records['path'].toString('utf8')).not.toBe('safe.txt')
|
||||
})
|
||||
|
||||
test('empty buffer parses to empty record set', () => {
|
||||
const {records, ok} = parsePaxLengthCorrect(Buffer.alloc(0))
|
||||
expect(ok).toBe(true)
|
||||
expect(Object.keys(records)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('truncated record: not ok', () => {
|
||||
// length says 42 but the buffer is shorter
|
||||
const buf = Buffer.from('42 path=too-short\n', 'ascii')
|
||||
const {ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
test('non-numeric length prefix: not ok', () => {
|
||||
const buf = Buffer.from('xx path=foo\n', 'ascii')
|
||||
const {ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
test('missing trailing newline: not ok', () => {
|
||||
// "16 path=safe.txt" is 16 bytes but the last byte is 't', not '\n'
|
||||
const buf = Buffer.from('16 path=safe.txt', 'ascii')
|
||||
const {ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
test('length spans past buffer end: not ok', () => {
|
||||
const buf = Buffer.from('99 path=x\n', 'ascii')
|
||||
const {ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
test('record without "=" : not ok', () => {
|
||||
const buf = Buffer.from('10 nokeyval\n', 'ascii')
|
||||
const {ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
test('record with correct length+LF but no "=" : not ok', () => {
|
||||
// "5 ab\n" is exactly 5 bytes and ends in LF, but has no '='. This
|
||||
// exercises the missing-separator branch (distinct from a length/LF
|
||||
// mismatch).
|
||||
const buf = Buffer.from('5 ab\n', 'ascii')
|
||||
const {ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
test('value may itself contain "=" — only the first one is the separator', () => {
|
||||
const buf = Buffer.from(rec('comment=a=b=c'), 'ascii')
|
||||
const {records, ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(true)
|
||||
expect(records['comment'].toString('utf8')).toBe('a=b=c')
|
||||
})
|
||||
|
||||
test('zero-length prefix is rejected', () => {
|
||||
const buf = Buffer.from('0 path=x\n', 'ascii')
|
||||
const {ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(false)
|
||||
})
|
||||
|
||||
test('high-bit value bytes are preserved as raw Buffer', () => {
|
||||
const value = Buffer.from([0xc3, 0x28, 0xff]) // invalid utf8 on purpose
|
||||
const inner = Buffer.concat([Buffer.from('path=', 'ascii'), value])
|
||||
// record = "<len> " + inner + "\n"
|
||||
const base = 1 + inner.length + 1
|
||||
let len = base + String(base).length
|
||||
if (String(len).length !== String(base).length)
|
||||
len = base + String(len).length
|
||||
const buf = Buffer.concat([
|
||||
Buffer.from(`${len} `, 'ascii'),
|
||||
inner,
|
||||
Buffer.from('\n', 'ascii')
|
||||
])
|
||||
const {records, ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(true)
|
||||
expect(Buffer.compare(records['path'], value)).toBe(0)
|
||||
})
|
||||
|
||||
test('last write wins for repeated keys', () => {
|
||||
const buf = Buffer.from('14 path=a.txt\n14 path=b.txt\n', 'ascii')
|
||||
const {records, ok} = parsePaxLengthCorrect(buf)
|
||||
expect(ok).toBe(true)
|
||||
expect(records['path'].toString('utf8')).toBe('b.txt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('crossCheckMetaBodies', () => {
|
||||
test('clean PAX path matching node-tar: no violations', () => {
|
||||
const buf = Buffer.from('17 path=safe.txt\n', 'ascii')
|
||||
const v = crossCheckMetaBodies([buf], 'safe.txt', undefined)
|
||||
expect(v).toEqual([])
|
||||
})
|
||||
|
||||
test('F2 path desync: node-tar resolved safe.txt, length-correct disagrees', () => {
|
||||
const buf = Buffer.from(
|
||||
'42 path=../../../../../../tmp/zip_slip_F2\n30 comment=x\n17 path=safe.txt\n',
|
||||
'ascii'
|
||||
)
|
||||
const v = crossCheckMetaBodies([buf], 'safe.txt', undefined)
|
||||
expect(v.map(x => x.code)).toContain('PAX_DESYNC')
|
||||
})
|
||||
|
||||
test('F2-linkpath desync: node-tar resolved safe/target, length-correct disagrees', () => {
|
||||
const buf = Buffer.from(
|
||||
'34 linkpath=../../../../../../tmp\n37 comment=x\n24 linkpath=safe/target\n',
|
||||
'ascii'
|
||||
)
|
||||
const v = crossCheckMetaBodies([buf], 'cache/link', 'safe/target')
|
||||
expect(v.map(x => x.code)).toContain('PAX_DESYNC')
|
||||
})
|
||||
|
||||
test('unknown PAX key is rejected', () => {
|
||||
const content = 'EVIL.placement=1'
|
||||
const base = 1 + content.length + 1
|
||||
let len = base + String(base).length
|
||||
if (String(len).length !== String(base).length)
|
||||
len = base + String(len).length
|
||||
const buf = Buffer.from(`${len} ${content}\n`, 'ascii')
|
||||
const v = crossCheckMetaBodies([buf], 'cache/x', undefined)
|
||||
expect(v.map(x => x.code)).toContain('PAX_UNKNOWN_KEY')
|
||||
})
|
||||
|
||||
test('known SCHILY/GNU/LIBARCHIVE prefixed keys are accepted', () => {
|
||||
const records = [
|
||||
'SCHILY.xattr.user.foo=bar',
|
||||
'GNU.sparse.realsize=1024',
|
||||
'LIBARCHIVE.creationtime=1700000000'
|
||||
]
|
||||
const body = records
|
||||
.map(content => {
|
||||
const base = 1 + content.length + 1
|
||||
let len = base + String(base).length
|
||||
if (String(len).length !== String(base).length)
|
||||
len = base + String(len).length
|
||||
return `${len} ${content}\n`
|
||||
})
|
||||
.join('')
|
||||
const v = crossCheckMetaBodies(
|
||||
[Buffer.from(body, 'ascii')],
|
||||
'cache/x',
|
||||
undefined
|
||||
)
|
||||
expect(v).toEqual([])
|
||||
})
|
||||
|
||||
test('GNU long-name raw body matching entry path: no violation', () => {
|
||||
// A GNU LongName body is a raw NUL-terminated path with no length prefix.
|
||||
const raw = Buffer.concat([
|
||||
Buffer.from('cache/a/very/long/name.txt', 'ascii'),
|
||||
Buffer.from([0, 0])
|
||||
])
|
||||
const v = crossCheckMetaBodies(
|
||||
[raw],
|
||||
'cache/a/very/long/name.txt',
|
||||
undefined
|
||||
)
|
||||
expect(v).toEqual([])
|
||||
})
|
||||
|
||||
test('long-name body that matches neither path nor linkpath is flagged', () => {
|
||||
const raw = Buffer.from('something-unaccountable', 'ascii')
|
||||
const v = crossCheckMetaBodies([raw], 'cache/x', undefined)
|
||||
expect(v.map(x => x.code)).toContain('PAX_PARSE_FAIL')
|
||||
})
|
||||
|
||||
test('no meta bodies: no violations', () => {
|
||||
expect(crossCheckMetaBodies([], 'cache/x', undefined)).toEqual([])
|
||||
})
|
||||
|
||||
test('PAX setting both path and linkpath in agreement: no violations', () => {
|
||||
const body = Buffer.from(
|
||||
rec('path=cache/link') + rec('linkpath=cache/target'),
|
||||
'ascii'
|
||||
)
|
||||
const v = crossCheckMetaBodies([body], 'cache/link', 'cache/target')
|
||||
expect(v).toEqual([])
|
||||
})
|
||||
|
||||
test('directory path with trailing slash compares equal (no false desync)', () => {
|
||||
// node-tar may resolve a directory entry without the trailing slash that
|
||||
// the PAX record carries; normalizeForCompare must treat them as equal.
|
||||
const body = Buffer.from(rec('path=cache/dir/'), 'ascii')
|
||||
const v = crossCheckMetaBodies([body], 'cache/dir', undefined)
|
||||
expect(v).toEqual([])
|
||||
})
|
||||
|
||||
test('multiple PAX bodies merge with last-write-wins before comparison', () => {
|
||||
const bodies = [
|
||||
Buffer.from(rec('path=cache/first'), 'ascii'),
|
||||
Buffer.from(rec('path=cache/second'), 'ascii')
|
||||
]
|
||||
// node-tar's resolved path is the last write; agreement => no violation.
|
||||
expect(crossCheckMetaBodies(bodies, 'cache/second', undefined)).toEqual([])
|
||||
// Disagreement with the merged (last) value => desync.
|
||||
expect(
|
||||
crossCheckMetaBodies(bodies, 'cache/first', undefined).map(x => x.code)
|
||||
).toContain('PAX_DESYNC')
|
||||
})
|
||||
|
||||
test('GNU long-name raw body matching the link target (not the path)', () => {
|
||||
const raw = Buffer.concat([
|
||||
Buffer.from('cache/sub/target', 'ascii'),
|
||||
Buffer.from([0])
|
||||
])
|
||||
const v = crossCheckMetaBodies([raw], 'cache/link', 'cache/sub/target')
|
||||
expect(v).toEqual([])
|
||||
})
|
||||
|
||||
test('PAX_KNOWN_KEYS includes path and linkpath', () => {
|
||||
expect(PAX_KNOWN_KEYS.has('path')).toBe(true)
|
||||
expect(PAX_KNOWN_KEYS.has('linkpath')).toBe(true)
|
||||
})
|
||||
})
|
||||
+112
-36
@@ -1,6 +1,7 @@
|
||||
import * as exec from '@actions/exec'
|
||||
import * as core from '@actions/core'
|
||||
import * as io from '@actions/io'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import {CompressionMethod} from '../src/internal/constants'
|
||||
import * as tar from '../src/internal/tar'
|
||||
@@ -82,23 +83,26 @@ describe('extractTar path validation integration', () => {
|
||||
})
|
||||
|
||||
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'
|
||||
}
|
||||
])
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
||||
approvedNames: [],
|
||||
violations: [
|
||||
{
|
||||
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)
|
||||
@@ -120,15 +124,18 @@ describe('extractTar path validation integration', () => {
|
||||
})
|
||||
|
||||
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'
|
||||
}
|
||||
])
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
||||
approvedNames: [],
|
||||
violations: [
|
||||
{
|
||||
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)
|
||||
@@ -144,15 +151,18 @@ describe('extractTar path validation integration', () => {
|
||||
|
||||
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(listAndValidate, 'listAndValidate').mockResolvedValue({
|
||||
approvedNames: [],
|
||||
violations: [
|
||||
{
|
||||
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)
|
||||
@@ -183,7 +193,7 @@ describe('extractTar path validation integration', () => {
|
||||
]
|
||||
jest
|
||||
.spyOn(listAndValidate, 'listAndValidate')
|
||||
.mockResolvedValue(violations)
|
||||
.mockResolvedValue({violations, approvedNames: []})
|
||||
jest.spyOn(core, 'warning').mockImplementation()
|
||||
jest.spyOn(core, 'debug').mockImplementation()
|
||||
|
||||
@@ -340,4 +350,70 @@ describe('extractTar path validation integration', () => {
|
||||
expect(listMock.mock.calls[0][1]).toBe(CompressionMethod.ZstdWithoutLong)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mode 'error' extraction allow-list", () => {
|
||||
test('clean archive: extraction restricted to approved members via --null/-T', async () => {
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
||||
violations: [],
|
||||
approvedNames: ['cache/a.txt', 'cache/b.txt']
|
||||
})
|
||||
jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
||||
|
||||
let command = ''
|
||||
let listPath: string | undefined
|
||||
let listContents: Buffer | undefined
|
||||
jest.spyOn(exec, 'exec').mockImplementation(async cmd => {
|
||||
command = cmd
|
||||
const m = /-T "([^"]+)"/.exec(command)
|
||||
if (m) {
|
||||
listPath = m[1]
|
||||
listContents = fs.readFileSync(listPath)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'error'
|
||||
})
|
||||
|
||||
// Allow-list flags are present and `--null` precedes `-T`.
|
||||
expect(command).toContain('--null')
|
||||
expect(command).toContain('--no-recursion')
|
||||
expect(command).toMatch(/--null .*-T "[^"]+"/)
|
||||
// GNU tar (the tool detected under the io.which mock on non-Windows)
|
||||
// additionally receives the wildcard-hardening flags.
|
||||
if (process.platform !== 'win32') {
|
||||
expect(command).toContain('--no-wildcards')
|
||||
expect(command).toContain('--anchored')
|
||||
}
|
||||
// The list is NUL-separated and contains exactly the approved names.
|
||||
expect(listContents?.toString('utf8')).toBe('cache/a.txt\0cache/b.txt\0')
|
||||
// The temporary allow-list file is cleaned up after extraction.
|
||||
expect(listPath).toBeDefined()
|
||||
expect(fs.existsSync(listPath as string)).toBe(false)
|
||||
})
|
||||
|
||||
test('warn mode does not restrict extraction (no -T) even when clean', async () => {
|
||||
jest.spyOn(listAndValidate, 'listAndValidate').mockResolvedValue({
|
||||
violations: [],
|
||||
approvedNames: ['cache/a.txt']
|
||||
})
|
||||
jest.spyOn(io, 'mkdirP').mockResolvedValue()
|
||||
|
||||
let command = ''
|
||||
jest.spyOn(exec, 'exec').mockImplementation(async cmd => {
|
||||
command = cmd
|
||||
return 0
|
||||
})
|
||||
|
||||
await tar.extractTar(archive, CompressionMethod.Gzip, {
|
||||
declaredPaths: ['cache/**'],
|
||||
pathValidation: 'warn'
|
||||
})
|
||||
|
||||
expect(command).not.toContain('-T "')
|
||||
expect(command).not.toContain('--no-recursion')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user