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]) }) test('non-leading ! is treated as a literal path character, not a glob', () => { // Regression: an earlier implementation included `!` in the glob // metachar set, so a declared path like `cache/my!dir/data` would // truncate to `cache/`, silently broadening the allowed root. // `@actions/glob`/minimatch treats `!` literally except as a leading // negation (handled by the case above), so non-leading `!` must be // preserved verbatim in the derived root. const input = IS_WINDOWS ? 'C:\\cache\\my!dir\\data' : '/cache/my!dir/data' const expected = IS_WINDOWS ? 'C:\\cache\\my!dir\\data' : '/cache/my!dir/data' expect(deriveAllowedRoots([input], cwd)).toEqual([expected]) }) }) 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 POSIX absolute target', () => { const r = validateEntry( 'node_modules/link', '/etc/passwd', 'SymbolicLink', allowedRoots, cwd ) expect(r.ok).toBe(false) // Syntactic rejection fires before the containment check. if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH') }) test('symlink with Windows absolute target', () => { const r = validateEntry( 'node_modules/link', 'C:\\Windows\\System32\\drivers\\etc\\hosts', 'SymbolicLink', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH') }) test('symlink with Windows drive-relative target', () => { // `C:foo` resolves against the per-drive working directory on Windows, // not the symlink's containing directory — so even if it happens to // land under an allowed root after `path.resolve`, its extract-time // semantics are different. Reject the syntactic form outright. const r = validateEntry( 'node_modules/link', 'C:foo', 'SymbolicLink', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH') }) test('symlink with UNC target', () => { const r = validateEntry( 'node_modules/link', '\\\\attacker\\share\\payload', 'SymbolicLink', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('UNC_PATH') }) test('symlink with UNC long-path prefix target', () => { const r = validateEntry( 'node_modules/link', '\\\\?\\C:\\foo', 'SymbolicLink', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('UNC_PATH') }) test('symlink with forward-slash UNC target', () => { const r = validateEntry( 'node_modules/link', '//attacker/share/payload', 'SymbolicLink', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('UNC_PATH') }) 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 POSIX 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('ABSOLUTE_PATH') }) test('hardlink with Windows absolute target', () => { const r = validateEntry( 'node_modules/dup', 'C:\\Windows\\System32\\drivers\\etc\\hosts', 'Link', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH') }) test('hardlink with UNC target', () => { const r = validateEntry( 'node_modules/dup', '\\\\attacker\\share\\payload', 'Link', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('UNC_PATH') }) 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. The absolute syntactic form `/etc` is now // rejected up-front (ABSOLUTE_PATH) before the containment check, which // is strictly stronger than the previous LINK_OUTSIDE_ROOTS reject // (the entry never gets the chance to be "absolute but happens to // resolve under a root"). const r = validateEntry( 'node_modules/x', '/etc', 'SymbolicLink', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH') }) test('symlink with absolute target that nominally lands under an allowed root is still rejected', () => { // Before the syntactic fix, a symlink whose absolute target happened // to resolve under an allowed root (e.g. POSIX `/workspace/.cache/x` // when /workspace/.cache is allowed) would pass validation. With the // syntactic reject, the absolute form alone is enough to fail — we // don't trust that extract-time resolution will match what // `path.resolve` says at validation time. const absoluteUnderRoot = IS_WINDOWS ? 'C:\\workspace\\.cache\\x' : '/workspace/.cache/x' const r = validateEntry( 'node_modules/link', absoluteUnderRoot, 'SymbolicLink', allowedRoots, cwd ) expect(r.ok).toBe(false) if (!r.ok) expect(r.code).toBe('ABSOLUTE_PATH') }) 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') }) })