Skip to content

Commit 4aa7490

Browse files
committed
feat: add downloadBaseURL and checksum inputs (closes #206)
1 parent 8756b85 commit 4aa7490

7 files changed

Lines changed: 796 additions & 129 deletions

File tree

.github/workflows/integration-tests.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,32 @@ jobs:
5454

5555
- name: Validate kubectl setup old version
5656
run: python test/validate-kubectl.py 'v1.15.1'
57+
58+
- name: Fetch known SHA256 for v1.30.0 linux/amd64
59+
id: sha
60+
run: |
61+
value=$(curl -fsSL https://dl.k8s.io/release/v1.30.0/bin/linux/amd64/kubectl.sha256)
62+
echo "value=$value" >> "$GITHUB_OUTPUT"
63+
64+
- name: Setup kubectl with valid checksum
65+
uses: ./
66+
with:
67+
version: 'v1.30.0'
68+
checksum: ${{ steps.sha.outputs.value }}
69+
70+
- name: Validate kubectl setup with checksum
71+
run: python test/validate-kubectl.py 'v1.30.0'
72+
73+
- name: Setup kubectl with bad checksum (expect failure)
74+
id: badsum
75+
continue-on-error: true
76+
uses: ./
77+
with:
78+
version: 'v1.29.0'
79+
checksum: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
80+
81+
- name: Assert bad-checksum step failed
82+
if: steps.badsum.outcome != 'failure'
83+
run: |
84+
echo "Expected bad-checksum step to fail, but outcome was: ${{ steps.badsum.outcome }}"
85+
exit 1

action.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ inputs:
55
description: 'Version of kubectl'
66
required: true
77
default: 'latest'
8+
downloadBaseURL:
9+
description: 'Base URL to download kubectl from (https only). Use for private mirrors.'
10+
required: false
11+
default: 'https://dl.k8s.io'
12+
checksum:
13+
description: 'Expected SHA256 of the kubectl binary. Recommended when overriding downloadBaseURL.'
14+
required: false
15+
default: ''
816
outputs:
917
kubectl-path:
1018
description: 'Path to the cached kubectl binary'

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"dependencies": {
2424
"@actions/core": "^3.0.1",
2525
"@actions/exec": "^3.0.0",
26+
"@actions/http-client": "^4.0.0",
2627
"@actions/tool-cache": "^4.0.0"
2728
},
2829
"devDependencies": {

src/helpers.ts

Lines changed: 168 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,143 @@
11
import * as os from 'os'
2-
import * as util from 'util'
32
import * as fs from 'fs'
3+
import * as path from 'path'
4+
import * as crypto from 'crypto'
45
import * as core from '@actions/core'
56
import * as toolCache from '@actions/tool-cache'
7+
import {HttpClient} from '@actions/http-client'
8+
9+
export const DEFAULT_KUBECTL_BASE_URL = 'https://dl.k8s.io'
10+
11+
export function normalizeBaseURL(input: string): string {
12+
const u = new URL(input)
13+
return `${u.protocol}//${u.host}${u.pathname.replace(/\/+$/, '')}`
14+
}
15+
16+
export function isDefaultBaseURL(input: string): boolean {
17+
try {
18+
return (
19+
normalizeBaseURL(input) === normalizeBaseURL(DEFAULT_KUBECTL_BASE_URL)
20+
)
21+
} catch {
22+
return false
23+
}
24+
}
25+
26+
const SECURE_DOWNLOAD_MAX_BYTES = 256 * 1024 * 1024
27+
28+
export async function secureDownload(downloadURL: string): Promise<string> {
29+
const client = new HttpClient('setup-kubectl', [], {
30+
allowRedirects: false
31+
})
32+
const response = await client.get(downloadURL)
33+
const status = response.message.statusCode
34+
35+
if (status && status >= 300 && status < 400) {
36+
const location = response.message.headers['location']
37+
response.message.resume()
38+
throw new Error(
39+
`Refusing redirect from custom downloadBaseURL (status ${status} -> ${location}).`
40+
)
41+
}
42+
if (status === 404) {
43+
response.message.resume()
44+
throw new toolCache.HTTPError(404)
45+
}
46+
if (status !== 200) {
47+
response.message.resume()
48+
throw new Error(`Download failed with status ${status}`)
49+
}
50+
51+
const contentLengthHeader = response.message.headers['content-length']
52+
const contentLength = Array.isArray(contentLengthHeader)
53+
? contentLengthHeader[0]
54+
: contentLengthHeader
55+
if (contentLength) {
56+
const declared = Number.parseInt(contentLength, 10)
57+
if (Number.isFinite(declared) && declared > SECURE_DOWNLOAD_MAX_BYTES) {
58+
response.message.resume()
59+
throw new Error(
60+
`Refusing download: Content-Length ${declared} exceeds cap ${SECURE_DOWNLOAD_MAX_BYTES} bytes.`
61+
)
62+
}
63+
}
64+
65+
const chunks: Buffer[] = []
66+
let received = 0
67+
for await (const chunk of response.message as AsyncIterable<Buffer>) {
68+
received += chunk.length
69+
if (received > SECURE_DOWNLOAD_MAX_BYTES) {
70+
response.message.destroy()
71+
throw new Error(
72+
`Refusing download: response body exceeded cap ${SECURE_DOWNLOAD_MAX_BYTES} bytes.`
73+
)
74+
}
75+
chunks.push(chunk)
76+
}
77+
78+
const tmpDir = process.env['RUNNER_TEMP'] || os.tmpdir()
79+
const tmpFile = path.join(tmpDir, `kubectl-${crypto.randomUUID()}`)
80+
fs.writeFileSync(tmpFile, Buffer.concat(chunks))
81+
return tmpFile
82+
}
83+
84+
export function validateBaseURL(input: string): URL {
85+
let url: URL
86+
try {
87+
url = new URL(input)
88+
} catch {
89+
throw new Error(`Invalid downloadBaseURL: "${input}" is not a valid URL.`)
90+
}
91+
92+
if (url.protocol !== 'https:') {
93+
throw new Error(
94+
`downloadBaseURL must use https://, got "${url.protocol}" in "${input}".`
95+
)
96+
}
97+
98+
if (url.username || url.password) {
99+
throw new Error(
100+
'downloadBaseURL must not contain userinfo (user:pass@host).'
101+
)
102+
}
103+
104+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '')
105+
const isLoopback =
106+
host === 'localhost' || host === '127.0.0.1' || host === '::1'
107+
const isLinkLocal = host.startsWith('169.254.')
108+
const isPrivateV4 =
109+
/^10\./.test(host) ||
110+
/^192\.168\./.test(host) ||
111+
/^172\.(1[6-9]|2\d|3[0-1])\./.test(host)
112+
// Gate IPv6 ranges on ':' so DNS labels like "fcd.example.com" aren't matched.
113+
const isIPv6Literal = host.includes(':')
114+
const isUniqueLocalV6 = isIPv6Literal && /^f[cd]/.test(host)
115+
const isLinkLocalV6 = isIPv6Literal && /^fe[89ab]/.test(host)
116+
const isUnspecified = host === '0.0.0.0' || host === '::'
117+
if (
118+
isLoopback ||
119+
isLinkLocal ||
120+
isPrivateV4 ||
121+
isUniqueLocalV6 ||
122+
isLinkLocalV6 ||
123+
isUnspecified
124+
) {
125+
throw new Error(
126+
`downloadBaseURL host "${host}" is loopback/link-local/private and is blocked by default.`
127+
)
128+
}
129+
130+
return url
131+
}
132+
133+
export function validateVersion(version: string): void {
134+
if (!/^v?\d+\.\d+\.\d+$/.test(version)) {
135+
throw new Error(
136+
`Invalid kubectl version: "${version}". Expected a value like "v1.30.0".`
137+
)
138+
}
139+
}
140+
6141
export function getKubectlArch(): string {
7142
const arch = os.arch()
8143
if (arch === 'x64') {
@@ -11,28 +146,51 @@ export function getKubectlArch(): string {
11146
return arch
12147
}
13148

14-
export function getkubectlDownloadURL(version: string, arch: string): string {
149+
export function getkubectlDownloadURL(
150+
version: string,
151+
arch: string,
152+
baseURL: string = DEFAULT_KUBECTL_BASE_URL
153+
): string {
154+
validateVersion(version)
155+
const url = validateBaseURL(baseURL)
156+
157+
let osDir: string
158+
let file: string
15159
switch (os.type()) {
16160
case 'Linux':
17-
return `https://dl.k8s.io/release/${version}/bin/linux/${arch}/kubectl`
18-
161+
osDir = 'linux'
162+
file = 'kubectl'
163+
break
19164
case 'Darwin':
20-
return `https://dl.k8s.io/release/${version}/bin/darwin/${arch}/kubectl`
21-
165+
osDir = 'darwin'
166+
file = 'kubectl'
167+
break
22168
case 'Windows_NT':
23169
default:
24-
return `https://dl.k8s.io/release/${version}/bin/windows/${arch}/kubectl.exe`
170+
osDir = 'windows'
171+
file = 'kubectl.exe'
172+
break
25173
}
174+
175+
const basePath = url.pathname.replace(/\/+$/, '')
176+
url.pathname = `${basePath}/release/${version}/bin/${osDir}/${arch}/${file}`
177+
return url.toString()
26178
}
27179

28180
export async function getLatestPatchVersion(
29181
major: string,
30-
minor: string
182+
minor: string,
183+
baseURL: string = DEFAULT_KUBECTL_BASE_URL
31184
): Promise<string> {
32185
const version = `${major}.${minor}`
33-
const sourceURL = `https://dl.k8s.io/release/stable-${version}.txt`
186+
const url = validateBaseURL(baseURL)
187+
const basePath = url.pathname.replace(/\/+$/, '')
188+
url.pathname = `${basePath}/release/stable-${version}.txt`
189+
const sourceURL = url.toString()
34190
try {
35-
const downloadPath = await toolCache.downloadTool(sourceURL)
191+
const downloadPath = isDefaultBaseURL(baseURL)
192+
? await toolCache.downloadTool(sourceURL)
193+
: await secureDownload(sourceURL)
36194
const latestPatch = fs
37195
.readFileSync(downloadPath, 'utf8')
38196
.toString()

0 commit comments

Comments
 (0)