Skip to content

Commit 2a1e5b0

Browse files
committed
fix(init): only send GITHUB_TOKEN to github hosts
1 parent 53c42f1 commit 2a1e5b0

2 files changed

Lines changed: 98 additions & 17 deletions

File tree

‎packages/nuxt-cli/src/utils/starter-templates.ts‎

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,36 @@ export interface TemplateData {
2222
tar: string
2323
}
2424

25-
constfetchOptions={
26-
// A proxy CONNECT plus TLS handshake on a corporate link regularly costs more
27-
// than 3s. The template list is prefetched and has a static fallback, so a
28-
// slightly longer deadline only ever delays the prompt on a broken network.
29-
timeout: 5000,
30-
headers: {
31-
'user-agent': '@nuxt/cli',
32-
...process.env.GITHUB_TOKEN ? {authorization: `token ${process.env.GITHUB_TOKEN}`} : {},
33-
},
34-
}asconst
25+
constGITHUB_HOSTS=newSet(['api.github.com','github.com','raw.githubusercontent.com','objects.githubusercontent.com'])
26+
27+
/**
28+
* Whether `url` is a GitHub host the `GITHUB_TOKEN` may be sent to. The listing
29+
* response chooses the download URLs, so the token follows the host allowlist
30+
* rather than the response.
31+
*/
32+
functionisGitHubURL(url: string): boolean{
33+
try{
34+
constparsed=newURL(url)
35+
returnparsed.protocol==='https:'&&GITHUB_HOSTS.has(parsed.hostname)
36+
}
37+
catch{
38+
returnfalse
39+
}
40+
}
41+
42+
exportfunctionfetchOptionsFor(url: string){
43+
consttoken=process.env.GITHUB_TOKEN
44+
return{
45+
// A proxy CONNECT plus TLS handshake on a corporate link regularly costs more
46+
// than 3s. The template list is prefetched and has a static fallback, so a
47+
// slightly longer deadline only ever delays the prompt on a broken network.
48+
timeout: 5000,
49+
headers: {
50+
'user-agent': '@nuxt/cli',
51+
...token&&isGitHubURL(url) ? {authorization: `token ${token}`} : {},
52+
},
53+
}
54+
}
3555

3656
exportconstTEMPLATES_API_URL='https://api.github.com/repos/nuxt/starter/contents/templates?ref=templates'
3757

@@ -43,23 +63,27 @@ export async function getTemplates() {
4363
}
4464

4565
exportasyncfunctionfetchTemplates(){
46-
consttemplates={}asRecord<string,TemplateData>
66+
consttemplates=Object.create(null)asRecord<string,TemplateData>
4767

4868
constfiles=awaitfetchJson<Array<{name: string,type: string,download_url?: string}>>(
4969
TEMPLATES_API_URL,
50-
fetchOptions,
70+
fetchOptionsFor(TEMPLATES_API_URL),
5171
)
5272

73+
if(!Array.isArray(files)){
74+
returntemplates
75+
}
76+
5377
awaitPromise.all(files.map(async(file)=>{
54-
if(!file.download_url||file.type!=='file'||!file.name.endsWith('.json')){
78+
if(!file?.download_url||file.type!=='file'||typeoffile.name!=='string'||!file.name.endsWith('.json')){
5579
return
5680
}
5781
consttemplateName=file.name.replace('.json','')
58-
if(hiddenTemplates.includes(templateName)){
82+
if(hiddenTemplates.includes(templateName)||!isGitHubURL(file.download_url)){
5983
return
6084
}
6185
templates[templateName]=undefinedasunknownasTemplateData
62-
templates[templateName]=awaitfetchJson<TemplateData>(file.download_url,fetchOptions)
86+
templates[templateName]=awaitfetchJson<TemplateData>(file.download_url,fetchOptionsFor(file.download_url))
6387
}))
6488

6589
returntemplates

‎packages/nuxt-cli/test/unit/utils/starter-templates.spec.ts‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
importprocessfrom'node:process'
2+
13
import{afterEach,describe,expect,it,vi}from'vitest'
24

35
import{describeNetworkError}from'../../../src/utils/network'
4-
import{fetchTemplates,TEMPLATES_API_URL}from'../../../src/utils/starter-templates'
6+
import{fetchOptionsFor,fetchTemplates,TEMPLATES_API_URL}from'../../../src/utils/starter-templates'
57

68
functionrespondAfter(delay: number){
79
returnvi.fn((url: string|URL|Request,init?: RequestInit)=>{
@@ -12,7 +14,7 @@ function respondAfter(delay: number) {
1214
// the host rather than a substring, which any URL could contain anywhere.
1315
constisListing=newURL(urlinstanceofRequest ? url.url : String(url)).host==='api.github.com'
1416
constbody=isListing
15-
? [{name: 'minimal.json',type: 'file',download_url: 'https://raw.example.com/minimal.json'}]
17+
? [{name: 'minimal.json',type: 'file',download_url: 'https://raw.githubusercontent.com/nuxt/starter/templates/templates/minimal.json'}]
1618
: {name: 'minimal',description: 'Minimal starter',defaultDir: 'nuxt-app',url: '',tar: ''}
1719

1820
returnnewPromise<Response>((resolve,reject)=>{
@@ -57,3 +59,58 @@ describe('fetchTemplates', () => {
5759
expect(describeNetworkError(error,TEMPLATES_API_URL)).toContain('timed out')
5860
},15_000)
5961
})
62+
63+
describe('github token handling',()=>{
64+
consttoken=process.env.GITHUB_TOKEN
65+
66+
afterEach(()=>{
67+
vi.unstubAllGlobals()
68+
if(token===undefined){
69+
deleteprocess.env.GITHUB_TOKEN
70+
}
71+
else{
72+
process.env.GITHUB_TOKEN=token
73+
}
74+
})
75+
76+
it('should send the token to github and to nowhere else',()=>{
77+
process.env.GITHUB_TOKEN='secret-token'
78+
79+
expect(fetchOptionsFor(TEMPLATES_API_URL).headers).toHaveProperty('authorization','token secret-token')
80+
expect(fetchOptionsFor('https://evil.example.com/minimal.json').headers).not.toHaveProperty('authorization')
81+
expect(fetchOptionsFor('http://api.github.com/x').headers).not.toHaveProperty('authorization')
82+
expect(fetchOptionsFor('https://api.github.com.evil.example.com/x').headers).not.toHaveProperty('authorization')
83+
})
84+
85+
it('should skip a template whose download url is not on github',async()=>{
86+
process.env.GITHUB_TOKEN='secret-token'
87+
constrequested: string[]=[]
88+
vi.stubGlobal('fetch',vi.fn((url: string|URL|Request)=>{
89+
consthref=urlinstanceofRequest ? url.url : String(url)
90+
requested.push(href)
91+
constbody=href===TEMPLATES_API_URL
92+
? [{name: 'evil.json',type: 'file',download_url: 'https://evil.example.com/evil.json'}]
93+
: {name: 'evil',description: '',defaultDir: 'nuxt-app',url: '',tar: ''}
94+
returnPromise.resolve(newResponse(JSON.stringify(body),{headers: {'content-type': 'application/json'}}))
95+
}))
96+
97+
awaitexpect(fetchTemplates()).resolves.toEqual({})
98+
expect(requested).toEqual([TEMPLATES_API_URL])
99+
})
100+
101+
it('should not let a template name reach the prototype',async()=>{
102+
vi.stubGlobal('fetch',vi.fn((url: string|URL|Request)=>{
103+
consthref=urlinstanceofRequest ? url.url : String(url)
104+
constbody=href===TEMPLATES_API_URL
105+
? [{name: '__proto__.json',type: 'file',download_url: 'https://raw.githubusercontent.com/nuxt/starter/templates/templates/__proto__.json'}]
106+
: {polluted: true}
107+
returnPromise.resolve(newResponse(JSON.stringify(body),{headers: {'content-type': 'application/json'}}))
108+
}))
109+
110+
consttemplates=awaitfetchTemplates()
111+
112+
expect(({}asRecord<string,unknown>).polluted).toBeUndefined()
113+
expect(Object.getPrototypeOf(templates)).toBeNull()
114+
expect(Object.hasOwn(templates,'__proto__')).toBe(true)
115+
})
116+
})

0 commit comments

Comments
 (0)