9.7 KiB
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:
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 |
__tests__/pathValidation.test.ts |
src/internal/listAndValidate.ts |
__tests__/listAndValidate.test.ts |
src/internal/tar.ts (integration into extractTar) |
__tests__/tarPathValidation.test.ts |
src/internal/cacheIntegrityError.ts |
covered indirectly via the integration tests |
src/options.ts (new pathValidation field) |
__tests__/options.test.ts |
src/cache.ts (forwarding + error re-throw) |
__tests__/restoreCache.test.ts, __tests__/restoreCacheV2.test.ts |
Unit tests — pure logic (pathValidation.test.ts)
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/bdropped when/ais also present) - Sibling-prefix non-collision (
/aais 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/, WindowsC:\..., Windows forward-slashC:/..., Windows drive-relativeC:foo, UNC\\server\share, UNC forward-slash, UNC long-path prefix\\?\C:\... - NUL byte attacks: NUL in path, NUL in symlink target
- Symlink attacks:
- Syntactic link-target rejects (these fire before the containment check,
on the same allow-list as entry-path syntax): POSIX absolute (
/etc/passwd), Windows absolute (C:\Windows\…), Windows drive-relative (C:foo— has per-drive-CWD semantics on Windows), backslash UNC (\\srv\share\x), forward-slash UNC (//srv/share/x), UNC long-path prefix (\\?\C:\…) - Absolute target that nominally lands under an allowed root is still
rejected — defense-in-depth regression:
path.resolveagreeing with the allowed root at validation time isn't trusted, because Windows extract-time resolution semantics can differ - Symlink target with
..traversal (rejected asLINK_OUTSIDE_ROOTS) - Symlink-then-write-through-link (the critical TOCTOU-style attack:
archive declares
cache/link → /tmp/evilfollowed bycache/link/file) - Self-referential symlink to
.
- Syntactic link-target rejects (these fire before the containment check,
on the same allow-list as entry-path syntax): POSIX absolute (
- Hardlink attacks:
- Syntactic link-target rejects: POSIX absolute, Windows absolute, UNC
..traversal (rejected asLINK_OUTSIDE_ROOTS)
- 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)
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/passwdtraversal → 1 violation, correct path/type - Absolute file path (
/etc/cron.d/evilorC:/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 asPARSE_ERROR)
Zstd-compressed (long & short window)
- Clean archive compressed with
zstd --long=30→ 0 violations - Traversal in zstd archive → 1 violation
ZstdWithoutLongcompression method also works
Integration tests — mocked downstream (tarPathValidation.test.ts)
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
optionsargument → 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.warningsummary, onecore.debugper violation, system tar still runs - Single violation → warning uses singular wording (
1 entry)
pathValidation: 'error'
- Violations present → throws
CacheIntegrityError, system tar is never invoked,mkdirPis 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
listAndValidatethrows → wrapped asCacheIntegrityError(PARSE_ERROR), system tar not invoked- Parse failure in
'warn'mode → warning is logged, validation is skipped, and extraction still proceeds
Plumbing
declaredPathsis forwarded tolistAndValidate- 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 plumbspathsandpathValidationrestoreCacheV2.test.ts— V2 restore plumbspathsandpathValidationoptions.test.ts—getDownloadOptionsdefaultspathValidation: 'off'
What is intentionally NOT tested at this layer
- End-to-end behaviour with real cache backend — covered by the
actions/cacheaction'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'sactionUtils+restoreImpltests. - 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
# 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