Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
14 changes: 12 additions & 2 deletions azure/setup-vp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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"
Expand Down
130 changes: 129 additions & 1 deletion src/azure/template.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, string> }>;

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<{
Expand Down Expand Up @@ -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");
});
},
);
}
});
Loading