mirror of
https://github.com/actions/cache.git
synced 2026-06-06 09:04:21 +00:00
277 lines
11 KiB
YAML
277 lines
11 KiB
YAML
name: Path Validation E2E
|
|
|
|
# Manually triggered only — this matrix runs 39 jobs with multiple cache
|
|
# saves/restores per job, which is too expensive to run on every PR. Maintainers
|
|
# should dispatch it from the Actions tab against a PR branch whenever changes
|
|
# touch the path-validation codepath (src/restoreImpl.ts, src/utils/actionUtils.ts,
|
|
# or the bundled @actions/cache version).
|
|
on:
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
# This workflow validates client-side path validation for action/cache restores.
|
|
# It is intentionally structured as two phases:
|
|
# 1. The "good cache" phase saves a legitimate cache, then restores it under
|
|
# each strict-paths value and asserts extraction succeeded.
|
|
# 2. The "poisoned cache" phase manufactures a tar archive that contains an
|
|
# entry whose path resolves outside the declared `path` input. It uploads
|
|
# that archive directly to the cache backend via the toolkit's internal
|
|
# APIs (not via this action), then attempts to restore it via this action
|
|
# under each strict-paths value and asserts the expected behavior:
|
|
# - off: the malicious entry is extracted (validation disabled).
|
|
# - warn: the malicious entry is extracted but a workflow warning is logged.
|
|
# - error: the malicious entry is rejected (no extraction).
|
|
#
|
|
# NOTE: The poisoned-cache phase requires a small Node.js helper script
|
|
# (__tests__/e2e/generate-poisoned-archive.mjs) that the test workflow invokes.
|
|
# We build the archive locally and upload it via the action under a strict-paths
|
|
# label so the cache key namespacing remains consistent.
|
|
|
|
jobs:
|
|
good-cache:
|
|
name: 'Restore legitimate cache (strict-paths=${{ matrix.strict-paths }})'
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022]
|
|
strict-paths: [off, warn, error]
|
|
runs-on: ${{ matrix.os }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v5
|
|
|
|
- name: Setup Node.js 24.x
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 24.x
|
|
|
|
- name: Install dependencies and build
|
|
run: |
|
|
npm ci
|
|
npm run build
|
|
|
|
- name: Generate legitimate cache files
|
|
shell: bash
|
|
run: |
|
|
mkdir -p path-validation-cache
|
|
for i in 1 2 3 4 5; do
|
|
echo "file-${i}-${{ matrix.os }}-${{ matrix.strict-paths }}" > "path-validation-cache/file-${i}.txt"
|
|
done
|
|
|
|
- name: Save legitimate cache
|
|
uses: ./
|
|
with:
|
|
key: path-validation-good-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
|
|
path: path-validation-cache
|
|
|
|
- name: Remove local files
|
|
shell: bash
|
|
run: rm -rf path-validation-cache
|
|
|
|
- name: Restore legitimate cache (should succeed under all modes)
|
|
id: restore
|
|
uses: ./
|
|
with:
|
|
key: path-validation-good-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
|
|
path: path-validation-cache
|
|
strict-paths: ${{ matrix.strict-paths }}
|
|
fail-on-cache-invalid: true
|
|
|
|
- name: Verify legitimate cache extracted
|
|
shell: bash
|
|
run: |
|
|
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
|
|
echo "::error::Expected cache-hit=true but got '${{ steps.restore.outputs.cache-hit }}'"
|
|
exit 1
|
|
fi
|
|
for i in 1 2 3 4 5; do
|
|
if [ ! -f "path-validation-cache/file-${i}.txt" ]; then
|
|
echo "::error::Missing expected file path-validation-cache/file-${i}.txt"
|
|
exit 1
|
|
fi
|
|
done
|
|
echo "Legitimate cache restored successfully under strict-paths=${{ matrix.strict-paths }}"
|
|
|
|
poisoned-cache:
|
|
name: 'Restore poisoned cache (strict-paths=${{ matrix.strict-paths }})'
|
|
needs: good-cache
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-latest, ubuntu-22.04, macos-latest, macos-13, windows-latest, windows-2022]
|
|
strict-paths: [off, warn, error]
|
|
runs-on: ${{ matrix.os }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v5
|
|
|
|
- name: Setup Node.js 24.x
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 24.x
|
|
|
|
- name: Install dependencies and build
|
|
run: |
|
|
npm ci
|
|
npm run build
|
|
|
|
# Build a tar.zst archive containing one legitimate entry plus one entry
|
|
# whose path escapes the declared `path` input. We then save it to the
|
|
# cache backend using the toolkit's saveCache() with the same path the
|
|
# restore step uses, so the archive header roots and declared paths match
|
|
# what the action expects.
|
|
- name: Generate poisoned archive locally
|
|
shell: bash
|
|
run: |
|
|
mkdir -p path-validation-cache
|
|
echo "legitimate" > path-validation-cache/legit.txt
|
|
# The escape file is created at the working-dir level (one above the
|
|
# declared path), simulating an attacker who built the cache from a
|
|
# workspace where the relative path "../escape.txt" pointed outside
|
|
# the declared `path: path-validation-cache` input.
|
|
echo "should-be-rejected" > escape.txt
|
|
|
|
- name: Save poisoned cache via toolkit helper
|
|
shell: bash
|
|
env:
|
|
POISONED_KEY: path-validation-bad-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
|
|
run: |
|
|
node __tests__/e2e/save-poisoned-cache.mjs \
|
|
"$POISONED_KEY" \
|
|
path-validation-cache \
|
|
../escape.txt
|
|
|
|
- name: Remove staged files (force restore to re-extract)
|
|
shell: bash
|
|
run: |
|
|
rm -rf path-validation-cache escape.txt
|
|
|
|
- name: Restore poisoned cache
|
|
id: restore
|
|
continue-on-error: true
|
|
uses: ./
|
|
with:
|
|
key: path-validation-bad-${{ matrix.os }}-${{ matrix.strict-paths }}-${{ github.run_id }}
|
|
path: path-validation-cache
|
|
strict-paths: ${{ matrix.strict-paths }}
|
|
# Always treat integrity failures as misses for this E2E so we can
|
|
# assert on outputs rather than job failure.
|
|
fail-on-cache-invalid: false
|
|
|
|
- name: Assert behavior for strict-paths=off (no validation)
|
|
if: matrix.strict-paths == 'off'
|
|
shell: bash
|
|
run: |
|
|
# In off mode the malicious entry IS extracted.
|
|
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
|
|
echo "::error::Expected cache-hit=true for strict-paths=off"
|
|
exit 1
|
|
fi
|
|
if [ ! -f "escape.txt" ]; then
|
|
echo "::error::Expected the malicious entry 'escape.txt' to be extracted in 'off' mode"
|
|
exit 1
|
|
fi
|
|
echo "OK: strict-paths=off extracted the cache without validation (expected legacy behavior)."
|
|
|
|
- name: Assert behavior for strict-paths=warn (warn but extract)
|
|
if: matrix.strict-paths == 'warn'
|
|
shell: bash
|
|
run: |
|
|
# In warn mode the cache IS extracted but a warning should be logged.
|
|
if [ "${{ steps.restore.outputs.cache-hit }}" != "true" ]; then
|
|
echo "::error::Expected cache-hit=true for strict-paths=warn"
|
|
exit 1
|
|
fi
|
|
if [ ! -f "escape.txt" ]; then
|
|
echo "::error::Expected the malicious entry 'escape.txt' to be extracted in 'warn' mode (warn does not reject)"
|
|
exit 1
|
|
fi
|
|
echo "OK: strict-paths=warn extracted the cache (a workflow warning should be visible in the action log above)."
|
|
|
|
- name: Assert behavior for strict-paths=error (reject)
|
|
if: matrix.strict-paths == 'error'
|
|
shell: bash
|
|
run: |
|
|
# In error mode the action should treat the cache as a miss
|
|
# (because fail-on-cache-invalid: false).
|
|
if [ -f "escape.txt" ]; then
|
|
echo "::error::Malicious entry 'escape.txt' was extracted in 'error' mode (validation failed open)"
|
|
exit 1
|
|
fi
|
|
if [ -f "path-validation-cache/legit.txt" ]; then
|
|
echo "::error::Cache was extracted in 'error' mode (validation should have rejected the entire archive)"
|
|
exit 1
|
|
fi
|
|
# The discarded cache must look identical to a regular cache miss
|
|
# to downstream `if:` checks (see issue #1466), so `cache-hit` is
|
|
# intentionally NOT set (empty string), NOT 'false'.
|
|
if [ -n "${{ steps.restore.outputs.cache-hit }}" ]; then
|
|
echo "::error::Expected cache-hit to be unset for rejected cache in 'error' mode (got '${{ steps.restore.outputs.cache-hit }}')"
|
|
exit 1
|
|
fi
|
|
echo "OK: strict-paths=error rejected the poisoned cache and treated it as a miss."
|
|
|
|
poisoned-cache-fail-on-invalid:
|
|
name: 'Reject poisoned cache (fail-on-cache-invalid=true)'
|
|
needs: poisoned-cache
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
runs-on: ${{ matrix.os }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v5
|
|
|
|
- name: Setup Node.js 24.x
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: 24.x
|
|
|
|
- name: Install dependencies and build
|
|
run: |
|
|
npm ci
|
|
npm run build
|
|
|
|
- name: Generate poisoned archive locally
|
|
shell: bash
|
|
run: |
|
|
mkdir -p path-validation-cache
|
|
echo "legitimate" > path-validation-cache/legit.txt
|
|
echo "should-be-rejected" > escape.txt
|
|
|
|
- name: Save poisoned cache via toolkit helper
|
|
shell: bash
|
|
env:
|
|
POISONED_KEY: path-validation-fail-${{ matrix.os }}-${{ github.run_id }}
|
|
run: |
|
|
node __tests__/e2e/save-poisoned-cache.mjs \
|
|
"$POISONED_KEY" \
|
|
path-validation-cache \
|
|
../escape.txt
|
|
|
|
- name: Remove staged files
|
|
shell: bash
|
|
run: |
|
|
rm -rf path-validation-cache escape.txt
|
|
|
|
- name: Attempt restore (expected to fail the workflow)
|
|
id: restore
|
|
continue-on-error: true
|
|
uses: ./
|
|
with:
|
|
key: path-validation-fail-${{ matrix.os }}-${{ github.run_id }}
|
|
path: path-validation-cache
|
|
strict-paths: error
|
|
fail-on-cache-invalid: true
|
|
|
|
- name: Assert the restore step failed
|
|
shell: bash
|
|
run: |
|
|
if [ "${{ steps.restore.outcome }}" != "failure" ]; then
|
|
echo "::error::Expected the restore step to fail when fail-on-cache-invalid=true and the cache is rejected (got outcome='${{ steps.restore.outcome }}')"
|
|
exit 1
|
|
fi
|
|
echo "OK: restore step failed as expected when fail-on-cache-invalid=true."
|