11import * as os from 'os'
2- import * as util from 'util'
32import * as fs from 'fs'
3+ import * as path from 'path'
4+ import * as crypto from 'crypto'
45import * as core from '@actions/core'
56import * 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+ / ^ 1 0 \. / . test ( host ) ||
110+ / ^ 1 9 2 \. 1 6 8 \. / . test ( host ) ||
111+ / ^ 1 7 2 \. ( 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 [ c d ] / . test ( host )
115+ const isLinkLocalV6 = isIPv6Literal && / ^ f e [ 8 9 a b ] / . 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+
6141export 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
28180export 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