Skip to content

Commit 38584ab

Browse files
authored
fix: validate refs before CI bootstrap downloads (#188)
An unchecked `setup-ref` can redirect GitLab downloads to another GitHub repository and execute its code on the runner. Both GitLab templates now check `setup-ref` before script interpolation and reject unsafe `SETUP_VP_SETUP_REF` values before bootstrap downloads. The GitLab and Azure bootstrap scripts also check refs before runtime downloads. Accepted refs contain ASCII letters, digits, underscores, and hyphens, with single dots or slashes between groups.
1 parent a0eccbf commit 38584ab

9 files changed

Lines changed: 205 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,8 @@ test:
467467

468468
### With GitLab Inputs
469469

470+
`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.
471+
470472
```yaml
471473
include:
472474
- remote: "https://raw.githubusercontent.com/voidzero-dev/setup-vp/v1.21.1/gitlab/setup-vp.yml"

‎azure/bootstrap.ps1‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ function Setup-VpDownload {
1414
}
1515

1616
$setupRef = if ($env:SETUP_VP_SETUP_REF) { $env:SETUP_VP_SETUP_REF } else { 'v1.21.1' }
17+
if ($setupRef -cnotmatch '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z') {
18+
throw 'setup-vp: invalid setupRef; use a tag, branch or commit SHA with safe ref characters.'
19+
}
1720
$runtimeOut = if ($env:SETUP_VP_RUNTIME_OUT) {
1821
$env:SETUP_VP_RUNTIME_OUT
1922
} else {

‎azure/bootstrap.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ setup_vp_download() {
2020
}
2121

2222
SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1.21.1}"
23+
if [[ ! "$SETUP_VP_SETUP_REF" =~ ^[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*$ ]]; then
24+
echo 'setup-vp: invalid setupRef; use a tag, branch or commit SHA with safe ref characters.' >&2
25+
exit 1
26+
fi
2327
SETUP_VP_RUNTIME_OUT="${SETUP_VP_RUNTIME_OUT:-${TMPDIR:-/tmp}/setup-vp-azure/dist/azure/index.mjs}"
2428
setup_vp_runtime_dir="$(dirname "$SETUP_VP_RUNTIME_OUT")"
2529
setup_vp_chunk_dir="$(dirname "$setup_vp_runtime_dir")"

‎gitlab/bootstrap.ps1‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
$ErrorActionPreference = 'Stop'
22
$runtimeNode = (Get-Command node -ErrorAction Stop).Source
33
$setupRef = if ($env:SETUP_VP_SETUP_REF) { $env:SETUP_VP_SETUP_REF } else { 'v1.21.1' }
4+
if ($setupRef -cnotmatch '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z') {
5+
throw 'setup-vp: invalid setup-ref; use a tag, branch or commit SHA with safe ref characters.'
6+
}
47
$runtime = Join-Path ([IO.Path]::GetTempPath()) ("setup-vp-gitlab-" + [guid]::NewGuid() + ".mjs")
58
try {
69
$url = "https://raw.githubusercontent.com/voidzero-dev/setup-vp/$setupRef/dist/gitlab/index.mjs"

‎gitlab/bootstrap.sh‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ setup_vp_download() {
1919
fi
2020
}
2121
SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1.21.1}"
22+
if [[ ! "$SETUP_VP_SETUP_REF" =~ ^[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*$ ]]; then
23+
echo 'setup-vp: invalid setup-ref; use a tag, branch or commit SHA with safe ref characters.' >&2
24+
exit 1
25+
fi
2226
setup_vp_runtime_dir="$(mktemp -d "${TMPDIR:-/tmp}/setup-vp-gitlab-runtime.XXXXXX")"
2327
setup_vp_runtime_tmp="$setup_vp_runtime_dir/index.mjs"
2428
trap 'rm -f "$setup_vp_runtime_tmp"; rmdir "$setup_vp_runtime_dir"' EXIT

‎gitlab/setup-vp-windows.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,16 @@ spec:
4747
setup-ref:
4848
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."
4949
default: "v1.21.1"
50+
regex: '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z'
5051
---
5152
.setup-vp-bootstrap:
5253
before_script:
5354
- |
5455
$ErrorActionPreference = 'Stop'
5556
if (-not $env:SETUP_VP_SETUP_REF) { $env:SETUP_VP_SETUP_REF = 'v1.21.1' }
57+
if ($env:SETUP_VP_SETUP_REF -cnotmatch '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z') {
58+
throw 'setup-vp: invalid setup-ref; use a tag, branch or commit SHA with safe ref characters.'
59+
}
5660
$bootstrap = Join-Path ([IO.Path]::GetTempPath()) ("setup-vp-bootstrap-" + [guid]::NewGuid() + ".ps1")
5761
$envFile = Join-Path ([IO.Path]::GetTempPath()) ("setup-vp-env-" + [guid]::NewGuid() + ".ps1")
5862
try {

‎gitlab/setup-vp.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ spec:
4747
setup-ref:
4848
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."
4949
default: "v1.21.1"
50+
regex: '\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\z'
5051
---
5152
.setup-vp-bootstrap:
5253
before_script:
@@ -81,6 +82,14 @@ spec:
8182
export SETUP_VP_SCOPE="${SETUP_VP_SCOPE:-}"
8283
export SETUP_VP_SETUP_REF="${SETUP_VP_SETUP_REF:-v1.21.1}"
8384
85+
# Keep this check POSIX-compatible: the runner may use sh before Bash starts.
86+
case "$SETUP_VP_SETUP_REF" in
87+
*[!ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_./-]*|[./]*|*[./]|*..*|*./*|*/.*|*//*)
88+
echo 'setup-vp: invalid setup-ref; use a tag, branch or commit SHA with safe ref characters.' >&2
89+
exit 1
90+
;;
91+
esac
92+
8493
setup_vp_bootstrap_tmp="$(mktemp "${TMPDIR:-/tmp}/setup-vp-gitlab-bootstrap.XXXXXX")"
8594
setup_vp_env_tmp="$(mktemp "${TMPDIR:-/tmp}/setup-vp-gitlab-env.XXXXXX")"
8695
chmod 600 "$setup_vp_env_tmp"

‎src/setup-ref.test.ts‎

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { spawnSync } from "node:child_process";
2+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { describe, expect, it } from "vite-plus/test";
6+
import { parseAllDocuments } from "yaml";
7+
8+
const { version } = JSON.parse(
9+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
10+
) as { version: string };
11+
12+
const safeRefs = [`v${version}`, "a".repeat(40), "main", "refs/tags/v1.21.1", "feature/test_ref-1"];
13+
const unsafeRefs = [
14+
"v1/../../../attacker/repo/main",
15+
"../other-repo/main",
16+
"refs/../main",
17+
"refs/./main",
18+
"refs//main",
19+
"/main",
20+
"main/",
21+
".main",
22+
"main.",
23+
"v1..2",
24+
"refs./main",
25+
"refs/.main",
26+
"v1/%2e%2e/%2e%2e/attacker/repo/main",
27+
"v1/%252e%252e/attacker/repo/main",
28+
"v1%2f..",
29+
"refs\\..\\main",
30+
"v1?query",
31+
"v1#fragment",
32+
"v1 with spaces",
33+
"v1\t",
34+
"v1\n",
35+
"v1\r\n",
36+
"v1\nmain",
37+
"v1\u0001",
38+
"rélease",
39+
"v1;exit 0",
40+
'$(printf injected > "$SETUP_VP_TEST_EXECUTED")',
41+
'`printf injected > "$SETUP_VP_TEST_EXECUTED"`',
42+
"$(Set-Content -LiteralPath $env:SETUP_VP_TEST_EXECUTED -Value injected)",
43+
];
44+
45+
type Shell = "sh" | "bash" | "powershell";
46+
47+
function readSource(file: string) {
48+
return readFileSync(new URL(`../${file}`, import.meta.url), "utf8");
49+
}
50+
51+
function readTemplate(file: string) {
52+
return parseAllDocuments(readSource(file), {
53+
customTags: [{ tag: "!reference", collection: "seq", resolve: (value) => value }],
54+
});
55+
}
56+
57+
function runScript(file: string, shell: Shell, setupRef: string | undefined) {
58+
const directory = mkdtempSync(join(tmpdir(), "setup-vp-setup-ref-"));
59+
const download = join(directory, "download");
60+
const executed = join(directory, "executed");
61+
const isTemplate = file.endsWith(".yml");
62+
const source = isTemplate
63+
? readTemplate(file)[1]!.toJSON()[".setup-vp-bootstrap"].before_script[0]
64+
: readSource(file);
65+
const payload = isTemplate
66+
? shell === "powershell"
67+
? '[IO.File]::WriteAllText($env:SETUP_VP_TEST_EXECUTED, "executed")'
68+
: 'printf executed > "$SETUP_VP_TEST_EXECUTED"'
69+
: 'import { writeFileSync } from "node:fs"; writeFileSync(process.env.SETUP_VP_TEST_EXECUTED, "executed");';
70+
const prelude =
71+
shell === "powershell"
72+
? `
73+
function Invoke-WebRequest {
74+
param([string]$Uri, [string]$OutFile, [int]$TimeoutSec)
75+
[IO.File]::WriteAllText($env:SETUP_VP_TEST_DOWNLOAD, $Uri)
76+
Copy-Item -LiteralPath $env:SETUP_VP_TEST_PAYLOAD -Destination $OutFile
77+
}
78+
`
79+
: `
80+
curl() {
81+
printf '%s' "$6" > "$SETUP_VP_TEST_DOWNLOAD"
82+
cp "$SETUP_VP_TEST_PAYLOAD" "$8"
83+
}
84+
wget() {
85+
printf '%s' "$8" > "$SETUP_VP_TEST_DOWNLOAD"
86+
cp "$SETUP_VP_TEST_PAYLOAD" "$7"
87+
}
88+
`;
89+
const scriptPath = join(directory, shell === "powershell" ? "test.ps1" : "test.sh");
90+
const payloadPath = join(directory, "payload");
91+
writeFileSync(scriptPath, prelude + source);
92+
writeFileSync(payloadPath, payload);
93+
try {
94+
const result = spawnSync(
95+
shell === "powershell" ? "powershell.exe" : shell,
96+
shell === "powershell"
97+
? ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath]
98+
: [scriptPath],
99+
{
100+
encoding: "utf8",
101+
timeout: 10_000,
102+
env: {
103+
...process.env,
104+
TMPDIR: directory,
105+
AGENT_TEMPDIRECTORY: directory,
106+
SETUP_VP_SETUP_REF: setupRef,
107+
SETUP_VP_RUNTIME_OUT: join(directory, "dist", "azure", "index.mjs"),
108+
SETUP_VP_TEST_DOWNLOAD: download,
109+
SETUP_VP_TEST_EXECUTED: executed,
110+
SETUP_VP_TEST_PAYLOAD: payloadPath,
111+
},
112+
},
113+
);
114+
expect(result.error).toBeUndefined();
115+
return {
116+
...result,
117+
download: existsSync(download) ? readFileSync(download, "utf8") : undefined,
118+
executed: existsSync(executed),
119+
};
120+
} finally {
121+
rmSync(directory, { recursive: true, force: true });
122+
}
123+
}
124+
125+
describe("setup-ref download boundaries", () => {
126+
it.each(["gitlab/setup-vp.yml", "gitlab/setup-vp-windows.yml"])(
127+
"%s constrains inputs before script interpolation",
128+
(file) => {
129+
const input = readTemplate(file)[0]!.toJSON().spec.inputs["setup-ref"];
130+
expect(input.regex).toBe("\\A[A-Za-z0-9_-]+([./][A-Za-z0-9_-]+)*\\z");
131+
},
132+
);
133+
134+
const targets: Array<[string, Shell, string]> = [
135+
["gitlab/setup-vp.yml", "sh", "gitlab/bootstrap.sh"],
136+
["gitlab/setup-vp.yml", "bash", "gitlab/bootstrap.sh"],
137+
["gitlab/setup-vp-windows.yml", "powershell", "gitlab/bootstrap.ps1"],
138+
["gitlab/bootstrap.sh", "bash", "dist/gitlab/index.mjs"],
139+
["gitlab/bootstrap.ps1", "powershell", "dist/gitlab/index.mjs"],
140+
["azure/bootstrap.sh", "bash", "dist/azure/index.mjs"],
141+
["azure/bootstrap.ps1", "powershell", "dist/azure/index.mjs"],
142+
];
143+
for (const [file, shell, downloadPath] of targets) {
144+
// Exercise the same native shells as each CI runner. Windows coverage runs
145+
// in the existing test-unit matrix in .github/workflows/test.yml.
146+
describe.skipIf((shell === "powershell") !== (process.platform === "win32"))(
147+
`${file} (${shell})`,
148+
() => {
149+
it.each([...safeRefs, "", undefined])("downloads and executes safe ref %j", (setupRef) => {
150+
const result = runScript(file, shell, setupRef);
151+
expect(result.status, result.stderr).toBe(0);
152+
expect(result.download).toBe(
153+
`https://raw.githubusercontent.com/voidzero-dev/setup-vp/${setupRef || `v${version}`}/${downloadPath}`,
154+
);
155+
expect(result.executed).toBe(true);
156+
});
157+
158+
it.each(unsafeRefs)(
159+
"rejects unsafe ref %j before any download or execution",
160+
(setupRef) => {
161+
const result = runScript(file, shell, setupRef);
162+
expect(result.status).not.toBe(0);
163+
expect(result.stderr).toContain(
164+
file.startsWith("azure/") ? "invalid setupRef" : "invalid setup-ref",
165+
);
166+
expect(result.download).toBeUndefined();
167+
expect(result.executed).toBe(false);
168+
},
169+
);
170+
},
171+
);
172+
}
173+
});

‎vite.config.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ const minifyOptions = {
1414
export default defineConfig({
1515
test: {
1616
include: ["src/**/*.test.ts"],
17+
// PowerShell and Git Bash subprocesses can exceed five seconds on Windows
18+
// CI runners. Allow startup overhead beyond the subprocess timeout.
19+
testTimeout: process.platform === "win32" ? 15_000 : 5_000,
1720
},
1821
staged: {
1922
"*": "vp check --fix",

0 commit comments

Comments
 (0)