diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ae55024..f88a62f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1137,6 +1137,12 @@ jobs: - name: Build portable bundles run: vp run build + # The full unit suite runs only on Ubuntu and skips PowerShell execution. + # Run these regressions in the OS matrix to exercise Bash on Linux/macOS + # and native powershell.exe on Windows; syntax checks cannot catch injection. + - name: Test Azure template ref validation and shell injection regressions + run: vp run test src/azure/template.test.ts + - name: Verify Azure runtime invalid phase smoke test shell: bash run: | diff --git a/README.md b/README.md index a3f4263..0cbbace 100644 --- a/README.md +++ b/README.md @@ -658,6 +658,8 @@ steps: Pin `ref` and `setupRef` to the same exact tag or commit SHA. Do not use the `v1` tag: it is frozen at v1.15.0 and no longer updated. +`setupRef` 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`). Other values fail before any bootstrap download. Keep this parameter under maintainer control because it selects code that runs on the agent. + ### Azure Parameters | Parameter | Default | Description | diff --git a/azure/setup-vp.yml b/azure/setup-vp.yml index 4e60e98..92274be 100644 --- a/azure/setup-vp.yml +++ b/azure/setup-vp.yml @@ -60,8 +60,13 @@ steps: - powershell: | $ErrorActionPreference = 'Stop' + # Read parameters as data, never as executable script source. + $setupRef = $env:SETUP_VP_SETUP_REF + 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.' + } $bootstrap = Join-Path $env:AGENT_TEMPDIRECTORY 'setup-vp-azure-bootstrap.ps1' - $base = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/${{ parameters.setupRef }}/azure" + $base = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/$setupRef/azure" Invoke-WebRequest -Uri "$base/bootstrap.ps1" -OutFile $bootstrap -TimeoutSec 60 & $bootstrap condition: and(succeeded(), eq(variables['Agent.OS'], 'Windows_NT')) @@ -85,8 +90,13 @@ steps: - bash: | set -euo pipefail + # Read parameters as data, never as executable script source. + 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 bootstrap="$(Agent.TempDirectory)/setup-vp-azure-bootstrap.sh" - base="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${{ parameters.setupRef }}/azure" + base="https://raw.githubusercontent.com/voidzero-dev/setup-vp/${SETUP_VP_SETUP_REF}/azure" curl -fsSL --connect-timeout 5 --max-time 60 "$base/bootstrap.sh" -o "$bootstrap" chmod +x "$bootstrap" bash "$bootstrap" diff --git a/src/azure/template.test.ts b/src/azure/template.test.ts index 9ca6f2d..1cde2db 100644 --- a/src/azure/template.test.ts +++ b/src/azure/template.test.ts @@ -1,4 +1,7 @@ -import { readFileSync } from "node:fs"; +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 { fileURLToPath } from "node:url"; import { describe, expect, it } from "vite-plus/test"; import { parse as parseYaml } from "yaml"; @@ -10,6 +13,73 @@ const { version } = JSON.parse( readFileSync(new URL("../../package.json", import.meta.url), "utf8"), ) as { version: string }; +type Shell = "bash" | "powershell"; +const prepareSteps = docs.steps.filter((step: { displayName?: string }) => + step.displayName?.startsWith("setup-vp prepare"), +) as Array<{ bash?: string; powershell?: string; env: Record }>; + +function prepareStep(shell: Shell) { + return prepareSteps.find((step) => step[shell])!; +} + +function runPrepare(shell: Shell, setupRef: string) { + const directory = mkdtempSync(join(tmpdir(), "setup-vp-azure-template-")); + const download = join(directory, "download"); + const bootstrap = join(directory, "bootstrap"); + const marker = join(directory, "injected"); + // Render the real template source as Azure would, so reintroducing inline + // parameter interpolation makes the injection payloads executable again. + const script = prepareStep(shell) + [shell]!.replaceAll("${{ parameters.setupRef }}", () => setupRef) + .replaceAll("$(Agent.TempDirectory)", () => directory); + const prelude = + shell === "bash" + ? ` +curl() { + printf '%s' "$6" > "$SETUP_VP_TEST_DOWNLOAD" + printf '%s\\n' 'printf "%s" "$SETUP_VP_SETUP_REF" > "$SETUP_VP_TEST_BOOTSTRAP"' > "$8" +} +` + : ` +function Invoke-WebRequest { + param([string]$Uri, [string]$OutFile, [int]$TimeoutSec) + [IO.File]::WriteAllText($env:SETUP_VP_TEST_DOWNLOAD, $Uri) + Set-Content -LiteralPath $OutFile -Value '[IO.File]::WriteAllText($env:SETUP_VP_TEST_BOOTSTRAP, $env:SETUP_VP_SETUP_REF)' +} +`; + const scriptPath = join(directory, shell === "bash" ? "prepare.sh" : "prepare.ps1"); + writeFileSync(scriptPath, prelude + script); + try { + const result = spawnSync( + shell === "bash" ? "bash" : "powershell.exe", + shell === "bash" + ? [scriptPath] + : ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath], + { + encoding: "utf8", + timeout: 10_000, + env: { + ...process.env, + AGENT_TEMPDIRECTORY: directory, + SETUP_VP_SETUP_REF: setupRef, + SETUP_VP_TEST_DOWNLOAD: download, + SETUP_VP_TEST_BOOTSTRAP: bootstrap, + SETUP_VP_TEST_MARKER: marker, + }, + }, + ); + expect(result.error).toBeUndefined(); + return { + ...result, + download: existsSync(download) ? readFileSync(download, "utf8") : undefined, + bootstrap: existsSync(bootstrap) ? readFileSync(bootstrap, "utf8") : undefined, + injected: existsSync(marker), + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + describe("azure/setup-vp.yml", () => { it("declares the documented parameters with defaults", () => { const parameters = docs.parameters as Array<{ @@ -80,4 +150,62 @@ describe("azure/setup-vp.yml", () => { "condition: and(succeeded(), ne(variables['Agent.OS'], 'Windows_NT'))", ); }); + + it.each(["bash", "powershell"] as const)( + "passes setupRef only through %s's environment", + (shell) => { + const step = prepareStep(shell); + expect(step.env.SETUP_VP_SETUP_REF).toBe("${{ parameters.setupRef }}"); + expect(step[shell]).not.toContain("${{"); + }, + ); + + for (const shell of ["bash", "powershell"] as const) { + describe.skipIf((shell === "powershell") !== (process.platform === "win32"))( + `${shell} prepare`, + () => { + it.each([`v${version}`, "a".repeat(40), "refs/tags/v1.21.1", "feature/test_ref-1"])( + "downloads and runs the bootstrap for %s", + (setupRef) => { + const result = runPrepare(shell, setupRef); + expect(result.status, result.stderr).toBe(0); + const extension = shell === "bash" ? "sh" : "ps1"; + expect(result.download).toBe( + `https://raw.githubusercontent.com/voidzero-dev/setup-vp/${setupRef}/azure/bootstrap.${extension}`, + ); + expect(result.bootstrap).toBe(setupRef); + expect(result.injected).toBe(false); + }, + ); + + it.each([ + "", + "../other-repo/main", + "refs/../main", + "refs/./main", + "refs//main", + "v1%2f..", + "v1?query", + "v1#fragment", + "v1 with spaces", + "v1\n", + "v1\r\n", + shell === "bash" + ? 'v1"; printf injected > "$SETUP_VP_TEST_MARKER"; exit 0; #' + : 'v1"; Set-Content -LiteralPath $env:SETUP_VP_TEST_MARKER -Value injected; exit 0; #', + shell === "bash" + ? '$(printf injected > "$SETUP_VP_TEST_MARKER")' + : "$(Set-Content -LiteralPath $env:SETUP_VP_TEST_MARKER -Value injected)", + '`printf injected > "$SETUP_VP_TEST_MARKER"`', + ])("rejects unsafe setupRef %j before downloading or executing commands", (setupRef) => { + const result = runPrepare(shell, setupRef); + expect(result.injected).toBe(false); + expect(result.download).toBeUndefined(); + expect(result.bootstrap).toBeUndefined(); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("invalid setupRef"); + }); + }, + ); + } });