From 528007014089f840e669b006df119e79b83e4c23 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 8 Oct 2026 12:38:44 +0800 Subject: [PATCH 1/2] fix: validate refs before CI bootstrap downloads --- README.md | 2 + azure/bootstrap.ps1 | 3 + azure/bootstrap.sh | 4 + gitlab/bootstrap.ps1 | 3 + gitlab/bootstrap.sh | 4 + gitlab/setup-vp-windows.yml | 4 + gitlab/setup-vp.yml | 9 ++ src/setup-ref.test.ts | 173 ++++++++++++++++++++++++++++++++++++ 8 files changed, 202 insertions(+) create mode 100644 src/setup-ref.test.ts diff --git a/README.md b/README.md index 0cbbace..844190e 100644 --- a/README.md +++ b/README.md @@ -467,6 +467,8 @@ test: ### With GitLab Inputs +`setup-ref` accepts ASCII letters, digits, underscores, and hyphens, with single dots or slashes between these groups (for example, `v1.21.1` or `refs/tags/v1.21.1`). The templates and bootstrap scripts reject other values before downloading executable files. The same validation applies to `SETUP_VP_SETUP_REF` when using `.setup-vp-bootstrap` directly. Keep this value under maintainer control because it selects code that runs on the runner. + ```yaml include: - remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1.21.1/gitlab/setup-vp.yml" diff --git a/azure/bootstrap.ps1 b/azure/bootstrap.ps1 index 9bdc319..6eb38c8 100644 --- a/azure/bootstrap.ps1 +++ b/azure/bootstrap.ps1 @@ -14,6 +14,9 @@ function Setup-VpDownload { } $setupRef = if ($env:SETUP_VP_SETUP_REF) { $env:SETUP_VP_SETUP_REF } else { 'v1.21.1' } +if ($setupRef -cnotmatch '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z') { + throw 'setup-vp: invalid setupRef; use a tag, branch or commit SHA with safe ref characters.' +} $runtimeOut = if ($env:SETUP_VP_RUNTIME_OUT) { $env:SETUP_VP_RUNTIME_OUT } else { diff --git a/azure/bootstrap.sh b/azure/bootstrap.sh index a871be5..89c3161 100644 --- a/azure/bootstrap.sh +++ b/azure/bootstrap.sh @@ -20,6 +20,10 @@ setup_vp_download() { } SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1.21.1}" +if [[ ! "$SETUP_VP_SETUP_REF" =~ ^[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*$ ]]; then + echo 'setup-vp: invalid setupRef; use a tag, branch or commit SHA with safe ref characters.' >&2 + exit 1 +fi SETUP_VP_RUNTIME_OUT="${SETUP_VP_RUNTIME_OUT:-${TMPDIR:-/tmp}/setup-vp-azure/dist/azure/index.mjs}" setup_vp_runtime_dir="$(dirname "$SETUP_VP_RUNTIME_OUT")" setup_vp_chunk_dir="$(dirname "$setup_vp_runtime_dir")" diff --git a/gitlab/bootstrap.ps1 b/gitlab/bootstrap.ps1 index bd11bbf..6fdb888 100644 --- a/gitlab/bootstrap.ps1 +++ b/gitlab/bootstrap.ps1 @@ -1,6 +1,9 @@ $ErrorActionPreference = 'Stop' $runtimeNode = (Get-Command node -ErrorAction Stop).Source $setupRef = if ($env:SETUP_VP_SETUP_REF) { $env:SETUP_VP_SETUP_REF } else { 'v1.21.1' } +if ($setupRef -cnotmatch '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z') { + throw 'setup-vp: invalid setup-ref; use a tag, branch or commit SHA with safe ref characters.' +} $runtime = Join-Path ([IO.Path]::GetTempPath()) ("setup-vp-gitlab-" + [guid]::NewGuid() + ".mjs") try { $url = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/$setupRef/dist/gitlab/index.mjs" diff --git a/gitlab/bootstrap.sh b/gitlab/bootstrap.sh index d942481..f3ccdfa 100644 --- a/gitlab/bootstrap.sh +++ b/gitlab/bootstrap.sh @@ -19,6 +19,10 @@ setup_vp_download() { fi } SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1.21.1}" +if [[ ! "$SETUP_VP_SETUP_REF" =~ ^[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*$ ]]; then + echo 'setup-vp: invalid setup-ref; use a tag, branch or commit SHA with safe ref characters.' >&2 + exit 1 +fi setup_vp_runtime_dir="$(mktemp -d "${TMPDIR:-/tmp}/setup-vp-gitlab-runtime.XXXXXX")" setup_vp_runtime_tmp="$setup_vp_runtime_dir/index.mjs" trap 'rm -f "$setup_vp_runtime_tmp"; rmdir "$setup_vp_runtime_dir"' EXIT diff --git a/gitlab/setup-vp-windows.yml b/gitlab/setup-vp-windows.yml index 89e3fe0..8d5a7f4 100644 --- a/gitlab/setup-vp-windows.yml +++ b/gitlab/setup-vp-windows.yml @@ -47,12 +47,16 @@ spec: setup-ref: description: "setup-vp ref used to download the GitLab bootstrap and compiled runtime. Pin this to the same tag or commit as the remote template; the default is the latest release when this template was published." default: "v1.21.1" + regex: '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z' --- .setup-vp-bootstrap: before_script: - | $ErrorActionPreference = 'Stop' if (-not $env:SETUP_VP_SETUP_REF) { $env:SETUP_VP_SETUP_REF = 'v1.21.1' } + if ($env:SETUP_VP_SETUP_REF -cnotmatch '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z') { + throw 'setup-vp: invalid setup-ref; use a tag, branch or commit SHA with safe ref characters.' + } $bootstrap = Join-Path ([IO.Path]::GetTempPath()) ("setup-vp-bootstrap-" + [guid]::NewGuid() + ".ps1") $envFile = Join-Path ([IO.Path]::GetTempPath()) ("setup-vp-env-" + [guid]::NewGuid() + ".ps1") try { diff --git a/gitlab/setup-vp.yml b/gitlab/setup-vp.yml index c4a20ad..1960ec6 100644 --- a/gitlab/setup-vp.yml +++ b/gitlab/setup-vp.yml @@ -47,6 +47,7 @@ spec: setup-ref: description: "setup-vp ref used to download the GitLab bootstrap and compiled runtime. Pin this to the same tag or commit as the remote template; the default is the latest release when this template was published." default: "v1.21.1" + regex: '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z' --- .setup-vp-bootstrap: before_script: @@ -81,6 +82,14 @@ spec: export SETUP_VP_SCOPE="${SETUP_VP_SCOPE:-}" export SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1.21.1}" + # Keep this check POSIX-compatible: the runner may use sh before Bash starts. + case "$SETUP_VP_SETUP_REF" in + *[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_./-]*|[./]*|*[./]|*..*|*./*|*/.*|*//*) + echo 'setup-vp: invalid setup-ref; use a tag, branch or commit SHA with safe ref characters.' >&2 + exit 1 + ;; + esac + setup_vp_bootstrap_tmp="$(mktemp "${TMPDIR:-/tmp}/setup-vp-gitlab-bootstrap.XXXXXX")" setup_vp_env_tmp="$(mktemp "${TMPDIR:-/tmp}/setup-vp-gitlab-env.XXXXXX")" chmod 600 "$setup_vp_env_tmp" diff --git a/src/setup-ref.test.ts b/src/setup-ref.test.ts new file mode 100644 index 0000000..06b75a2 --- /dev/null +++ b/src/setup-ref.test.ts @@ -0,0 +1,173 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { parseAllDocuments } from "yaml"; + +const { version } = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as { version: string }; + +const safeRefs = [`v${version}`, "a".repeat(40), "main", "refs/tags/v1.21.1", "feature/test_ref-1"]; +const unsafeRefs = [ + "v1/../../../attacker/repo/main", + "../other-repo/main", + "refs/../main", + "refs/./main", + "refs//main", + "/main", + "main/", + ".main", + "main.", + "v1..2", + "refs./main", + "refs/.main", + "v1/%2e%2e/%2e%2e/attacker/repo/main", + "v1/%252e%252e/attacker/repo/main", + "v1%2f..", + "refs\\..\\main", + "v1?query", + "v1#fragment", + "v1 with spaces", + "v1\t", + "v1\n", + "v1\r\n", + "v1\nmain", + "v1\u0001", + "rélease", + "v1;exit 0", + '$(printf injected > "$SETUP_VP_TEST_EXECUTED")', + '`printf injected > "$SETUP_VP_TEST_EXECUTED"`', + "$(Set-Content -LiteralPath $env:SETUP_VP_TEST_EXECUTED -Value injected)", +]; + +type Shell = "sh" | "bash" | "powershell"; + +function readSource(file: string) { + return readFileSync(new URL(`../${file}`, import.meta.url), "utf8"); +} + +function readTemplate(file: string) { + return parseAllDocuments(readSource(file), { + customTags: [{ tag: "!reference", collection: "seq", resolve: (value) => value }], + }); +} + +function runScript(file: string, shell: Shell, setupRef: string | undefined) { + const directory = mkdtempSync(join(tmpdir(), "setup-vp-setup-ref-")); + const download = join(directory, "download"); + const executed = join(directory, "executed"); + const isTemplate = file.endsWith(".yml"); + const source = isTemplate + ? readTemplate(file)[1]!.toJSON()[".setup-vp-bootstrap"].before_script[0] + : readSource(file); + const payload = isTemplate + ? shell === "powershell" + ? '[IO.File]::WriteAllText($env:SETUP_VP_TEST_EXECUTED, "executed")' + : 'printf executed > "$SETUP_VP_TEST_EXECUTED"' + : 'import { writeFileSync } from "node:fs"; writeFileSync(process.env.SETUP_VP_TEST_EXECUTED, "executed");'; + const prelude = + shell === "powershell" + ? ` +function Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [int]$TimeoutSec) + [IO.File]::WriteAllText($env:SETUP_VP_TEST_DOWNLOAD, $Uri) + Copy-Item -LiteralPath $env:SETUP_VP_TEST_PAYLOAD -Destination $OutFile +} +` + : ` +curl() { + printf '%s' "$6" > "$SETUP_VP_TEST_DOWNLOAD" + cp "$SETUP_VP_TEST_PAYLOAD" "$8" +} +wget() { + printf '%s' "$8" > "$SETUP_VP_TEST_DOWNLOAD" + cp "$SETUP_VP_TEST_PAYLOAD" "$7" +} +`; + const scriptPath = join(directory, shell === "powershell" ? "test.ps1" : "test.sh"); + const payloadPath = join(directory, "payload"); + writeFileSync(scriptPath, prelude + source); + writeFileSync(payloadPath, payload); + try { + const result = spawnSync( + shell === "powershell" ? "powershell.exe" : shell, + shell === "powershell" + ? ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath] + : [scriptPath], + { + encoding: "utf8", + timeout: 10_000, + env: { + ...process.env, + TMPDIR: directory, + AGENT_TEMPDIRECTORY: directory, + SETUP_VP_SETUP_REF: setupRef, + SETUP_VP_RUNTIME_OUT: join(directory, "dist", "azure", "index.mjs"), + SETUP_VP_TEST_DOWNLOAD: download, + SETUP_VP_TEST_EXECUTED: executed, + SETUP_VP_TEST_PAYLOAD: payloadPath, + }, + }, + ); + expect(result.error).toBeUndefined(); + return { + ...result, + download: existsSync(download) ? readFileSync(download, "utf8") : undefined, + executed: existsSync(executed), + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +describe("setup-ref download boundaries", () => { + it.each(["gitlab/setup-vp.yml", "gitlab/setup-vp-windows.yml"])( + "%s constrains inputs before script interpolation", + (file) => { + const input = readTemplate(file)[0]!.toJSON().spec.inputs["setup-ref"]; + expect(input.regex).toBe("\\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\\z"); + }, + ); + + const targets: Array<[string, Shell, string]> = [ + ["gitlab/setup-vp.yml", "sh", "gitlab/bootstrap.sh"], + ["gitlab/setup-vp.yml", "bash", "gitlab/bootstrap.sh"], + ["gitlab/setup-vp-windows.yml", "powershell", "gitlab/bootstrap.ps1"], + ["gitlab/bootstrap.sh", "bash", "dist/gitlab/index.mjs"], + ["gitlab/bootstrap.ps1", "powershell", "dist/gitlab/index.mjs"], + ["azure/bootstrap.sh", "bash", "dist/azure/index.mjs"], + ["azure/bootstrap.ps1", "powershell", "dist/azure/index.mjs"], + ]; + for (const [file, shell, downloadPath] of targets) { + // Exercise the same native shells as each CI runner. Windows coverage runs + // in the existing test-unit matrix in .github/workflows/test.yml. + describe.skipIf((shell === "powershell") !== (process.platform === "win32"))( + `${file} (${shell})`, + () => { + it.each([...safeRefs, "", undefined])("downloads and executes safe ref %j", (setupRef) => { + const result = runScript(file, shell, setupRef); + expect(result.status, result.stderr).toBe(0); + expect(result.download).toBe( + `https://raw.githubusercontent.com/voidzero-dev/setup-vp/${setupRef || `v${version}`}/${downloadPath}`, + ); + expect(result.executed).toBe(true); + }); + + it.each(unsafeRefs)( + "rejects unsafe ref %j before any download or execution", + (setupRef) => { + const result = runScript(file, shell, setupRef); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + file.startsWith("azure/") ? "invalid setupRef" : "invalid setup-ref", + ); + expect(result.download).toBeUndefined(); + expect(result.executed).toBe(false); + }, + ); + }, + ); + } +}); From a7218f877c177684cca8c24010b55359c25571e4 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 8 Oct 2026 12:43:18 +0800 Subject: [PATCH 2/2] test: allow Windows subprocess startup delays --- vite.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vite.config.ts b/vite.config.ts index dc67bb1..7a98900 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,9 @@ const minifyOptions = { export default defineConfig({ test: { include: ["src/**/*.test.ts"], + // PowerShell and Git Bash subprocesses can exceed five seconds on Windows + // CI runners. Allow startup overhead beyond the subprocess timeout. + testTimeout: process.platform === "win32" ? 15_000 : 5_000, }, staged: { "*": "vp check --fix",