Files
toolkit/packages/cache/docs/path-validation-test-plan.md
T

322 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.2.0.
## Feature summary
`extractTar()` now accepts a third argument:
```ts
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). Beyond simple
containment, the validator also defends against parser differentials between
`node-tar` (used for listing) and system `tar` (used for extraction):
length-correct PAX extended-header re-parsing, unknown-PAX-key rejection, and
unsafe-character / glob-metacharacter rejection. `listAndValidate` returns both
the collected `violations` and the `approvedNames` (the exact member names
`node-tar` derived from the archive bytes).
Violations are collected; in `'error'` mode a `CacheIntegrityError` is thrown
**before** system tar is invoked, so no bytes are ever written to the workspace.
In `'error'` mode on a **clean** archive, extraction is additionally restricted
to exactly the `approvedNames` via a NUL-separated `tar --null --no-recursion
-T` allow-list, so a member that system `tar` would place at a different path
than `node-tar` computed is never extracted. `'warn'` mode never uses the
allow-list — on any violation it falls back to extracting everything (legacy
behavior).
`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`](../src/internal/pathValidation.ts) | [`__tests__/pathValidation.test.ts`](../__tests__/pathValidation.test.ts) |
| [`src/internal/pax-reparse.ts`](../src/internal/pax-reparse.ts) | [`__tests__/pax-reparse.test.ts`](../__tests__/pax-reparse.test.ts) |
| [`src/internal/listAndValidate.ts`](../src/internal/listAndValidate.ts) | [`__tests__/listAndValidate.test.ts`](../__tests__/listAndValidate.test.ts), [`__tests__/tarPathValidationAttacks.test.ts`](../__tests__/tarPathValidationAttacks.test.ts) |
| [`src/internal/tar.ts`](../src/internal/tar.ts) (integration into `extractTar`) | [`__tests__/tarPathValidation.test.ts`](../__tests__/tarPathValidation.test.ts), [`__tests__/tarPathValidationAttacks.test.ts`](../__tests__/tarPathValidationAttacks.test.ts) |
| [`src/internal/cacheIntegrityError.ts`](../src/internal/cacheIntegrityError.ts) | covered indirectly via the integration tests |
| [`src/options.ts`](../src/options.ts) (new `pathValidation` field) | [`__tests__/options.test.ts`](../__tests__/options.test.ts) |
| [`src/cache.ts`](../src/cache.ts) (forwarding + error re-throw) | [`__tests__/restoreCache.test.ts`](../__tests__/restoreCache.test.ts), [`__tests__/restoreCacheV2.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/b` dropped when `/a` is also present)
- Sibling-prefix non-collision (`/aa` is 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 `/`, Windows `C:\...`, Windows forward-slash
`C:/...`, Windows drive-relative `C:foo`, UNC `\\server\share`, UNC forward-slash,
UNC long-path prefix `\\?\C:\...`
- **NUL byte attacks**: NUL in path, NUL in symlink target
- **Unsafe control characters**: embedded newline in an entry path or link
target → `UNSAFE_CHAR` (would corrupt a newline-delimited tar file list and
enables log injection). A tab is still accepted (legal, non-corrupting).
- **Glob metacharacters** (`* ? [ ]`) in an entry path → `GLOB_METACHAR`
(entry paths populate the system-tar `-T` allow-list, which bsdtar matches
with `fnmatch()`; link targets are exempt since they are not written there)
- **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.resolve` agreeing with
the allowed root at validation time isn't trusted, because Windows
extract-time resolution semantics can differ
- Symlink target with `..` traversal (rejected as `LINK_OUTSIDE_ROOTS`)
- **Symlink-then-write-through-link** (the critical TOCTOU-style attack:
archive declares `cache/link → /tmp/evil` followed by `cache/link/file`)
- Self-referential symlink to `.`
- **Hardlink attacks**:
- Syntactic link-target rejects: POSIX absolute, Windows absolute, UNC
- `..` traversal (rejected as `LINK_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
## Unit tests — PAX re-parser (`pax-reparse.test.ts`)
These exercise the length-correct PAX extended-header re-parser in isolation
(pure logic, no archives). `node-tar` parses PAX bodies with a naive
`split('\n')`, which desynchronises from GNU tar / libarchive on values that
contain embedded newlines. This module re-parses each body byte-accurately and
cross-checks the result against `node-tar`'s view of the entry.
### `parsePaxLengthCorrect`
- Single well-formed `"<len> <key>=<value>\n"` record
- **Newline-in-value**: an embedded fake `path=` record is swallowed by the
enclosing record's length, rather than parsed as its own record (the core
parser-differential)
- Empty buffer → empty record set
- `ok: false` for: truncated record, non-numeric length prefix, missing
trailing newline, length that spans past the buffer end, record without `=`,
record with correct length + LF but no `=`, zero-length prefix
- Value may itself contain `=` — only the first one is the separator
- High-bit value bytes preserved as a raw `Buffer`
- Last write wins for repeated keys
### `crossCheckMetaBodies`
- Clean PAX `path` matching `node-tar` → no violations
- **PAX path desync** → `PAX_DESYNC` (node-tar resolved a safe name, the
length-correct parse disagrees)
- **PAX linkpath desync** → `PAX_DESYNC`
- Unknown PAX key → `PAX_UNKNOWN_KEY`
- **`GNU.sparse.*` keys → `PAX_UNSUPPORTED_KEY`** — node-tar v7 ignores GNU
sparse keys, so it surfaces the entry under its header `path` while system
tar would reconstruct the file at `GNU.sparse.name` (an attacker-controlled,
potentially escaping location). A cache archive never legitimately contains
sparse members, so the namespace is rejected outright even though it falls
under the broadly-allowed `GNU.` prefix.
- Known `SCHILY.` / `LIBARCHIVE.` prefixed keys accepted
- GNU long-name raw body matching the entry path (or the link target) → no
violation; a body matching neither → flagged
- No meta bodies → no violations
- PAX setting both `path` and `linkpath` in agreement → no violations
- Directory path with trailing slash compares equal (no false desync)
- Multiple PAX bodies merge with last-write-wins before comparison
- `PAX_KNOWN_KEYS` includes `path` and `linkpath`
## Integration tests — parser-differential attacks (`tarPathValidationAttacks.test.ts`)
These build malicious archives as **raw tar bytes**, so they can craft PAX
bodies and typeflags that `node-tar`'s `Header` encoder would never emit. Each
is designed to make node-tar's in-process listing disagree with the path the
system `tar` extractor would write to. They assert the validator refuses each
one, and they include real-`tar` end-to-end extraction assertions (using a
scratch dir via `mkdtempSync`).
### Bypass detection (via `listAndValidate`)
- Unknown typeflag → `UNSUPPORTED_TYPE`
- PAX `path` newline differential → `PAX_DESYNC`
- PAX `linkpath` newline differential → `PAX_DESYNC`
- Oversized PAX header → `UNSUPPORTED_TYPE`
- GNU sparse typeflag → `UNSUPPORTED_TYPE`
- **PAX sparse** (`GNU.sparse.name` with a regular typeflag) → `PAX_UNSUPPORTED_KEY`,
and the escaping sparse name is not approved
- Glob metacharacter in entry path → `GLOB_METACHAR`
- Newline in entry path → `UNSAFE_CHAR`
- NUL byte in a symlink target (delivered via PAX) → `NUL_BYTE`
- Unknown PAX key → `PAX_UNKNOWN_KEY`
- Flood of extended headers → rejected by the pending-meta cap
- Clean archive → `approvedNames` lists every concrete entry, no violations
- Legitimate long path via PAX → no violations, approved by its PAX path
### End-to-end extraction (real `tar`)
Run against whichever system `tar` is present, so CI exercises GNU tar on Linux
and bsdtar on macOS (and `tar.exe` on Windows):
- `'error'` mode, clean archive → every approved member is extracted
- `'error'` mode, PAX path newline archive → throws `CacheIntegrityError` and
writes nothing to the workspace
- `'error'` mode, leading `./` entry → still extracted (the `-T` allow-list
uses the canonical `cache/f` name, so the member is not silently skipped)
- `'error'` mode, long path (> 100 bytes) delivered via PAX → extracted, not
dropped by the allow-list (exercises long-name matching in `-T`)
## 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/passwd` traversal → 1 violation, correct path/type
- Absolute file path (`/etc/cron.d/evil` or `C:/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 as `PARSE_ERROR`)
### Zstd-compressed (long & short window)
- Clean archive compressed with `zstd --long=30` → 0 violations
- Traversal in zstd archive → 1 violation
- `ZstdWithoutLong` compression 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. The mock returns the production
shape `{violations, approvedNames}`. They mock `@actions/exec`, `@actions/io`
and `@actions/core` to assert what does (and does not) get called.
### `pathValidation: 'off'` (default)
- No `options` argument → 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.warning` summary, one `core.debug`
per violation, system tar **still runs**
- Single violation → warning uses singular wording (`1 entry`)
### `pathValidation: 'error'`
- Violations present → throws `CacheIntegrityError`, **system tar is never
invoked, `mkdirP` is 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
- `listAndValidate` throws → wrapped as `CacheIntegrityError(PARSE_ERROR)`,
system tar not invoked
- Parse failure in `'warn'` mode → warning is logged, validation is skipped,
and extraction still proceeds
### `pathValidation: 'error'` extraction allow-list
- Clean archive → extraction is restricted to the approved members: the command
contains `--null` immediately before `-T "<file>"`, the `-T` file holds the
NUL-separated `approvedNames`, and the temporary allow-list file is cleaned up
after extraction
- `'warn'` mode does **not** restrict extraction (no `-T`) even on a clean
archive
### Plumbing
- `declaredPaths` is forwarded to `listAndValidate`
- 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 plumbs `paths` and `pathValidation`
- `restoreCacheV2.test.ts` — V2 restore plumbs `paths` and `pathValidation`
- `options.test.ts``getDownloadOptions` defaults `pathValidation: 'off'`
## What is intentionally NOT tested at this layer
- **End-to-end behaviour with real cache backend** — covered by the
`actions/cache` action'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's `actionUtils` + `restoreImpl` tests.
- **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
```sh
# 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__/pax-reparse.test.ts \
packages/cache/__tests__/listAndValidate.test.ts \
packages/cache/__tests__/tarPathValidation.test.ts \
packages/cache/__tests__/tarPathValidationAttacks.test.ts
```