Skip to content

Commit a7b2718

Browse files
authored
fix(devtools): require dev auth token for openInEditor and options RPC methods (#1039)
1 parent e65c8a9 commit a7b2718

13 files changed

Lines changed: 173 additions & 25 deletions

File tree

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
"lint": "eslint --cache .",
1515
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s",
1616
"release": "pnpm test && bumpp -r -x \"pnpm run changelog\" --all",
17-
"test": "pnpm lint",
17+
"test": "pnpm lint && pnpm test:unit",
18+
"test:unit": "pnpm -r --filter \"./packages/*\" run test:unit",
1819
"docs": "nuxi dev docs",
1920
"docs:build": "CI=true nuxi generate docs",
2021
"typecheck": "vue-tsc --noEmit",

‎packages/devtools-kit/src/_types/rpc.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ export interface ServerFunctions {
3131

3232
// Options
3333
getOptions: <TextendskeyofNuxtDevToolsOptions>(tab: T)=>Promise<NuxtDevToolsOptions[T]>
34-
updateOptions: <TextendskeyofNuxtDevToolsOptions>(tab: T,settings: Partial<NuxtDevToolsOptions[T]>)=>Promise<void>
35-
clearOptions: ()=>Promise<void>
34+
updateOptions: <TextendskeyofNuxtDevToolsOptions>(token: string,tab: T,settings: Partial<NuxtDevToolsOptions[T]>)=>Promise<void>
35+
clearOptions: (token: string)=>Promise<void>
3636

3737
// Updates
3838
checkForUpdateFor: (name: string)=>Promise<PackageUpdateInfo|undefined>
@@ -68,7 +68,7 @@ export interface ServerFunctions {
6868
telemetryEvent: (payload: object,immediate?: boolean)=>void
6969
customTabAction: (name: string,action: number)=>Promise<boolean>
7070
runWizard: <TextendsWizardActions>(token: string,name: T, ...args: GetWizardArgs<T>)=>Promise<void>
71-
openInEditor: (filepath: string)=>Promise<boolean>
71+
openInEditor: (token: string,filepath: string)=>Promise<boolean>
7272
restartNuxt: (token: string,hard?: boolean)=>Promise<void>
7373
installNuxtModule: (token: string,name: string,dry?: boolean)=>Promise<InstallModuleReturn>
7474
uninstallNuxtModule: (token: string,name: string,dry?: boolean)=>Promise<InstallModuleReturn>

‎packages/devtools/client/composables/editor.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import{useClipboard}from'@vueuse/core'
22
import{useRouter}from'#app/composables/router'
33
import{devtoolsUiShowNotification}from'#imports'
4+
import{ensureDevAuthToken}from'./dev-auth'
45
import{rpc}from'./rpc'
56
import{useServerConfig,useVirtualFiles}from'./state'
67
import{useCurrentVirtualFile}from'./state-routes'
@@ -33,7 +34,7 @@ export function useOpenInEditor() {
3334
router.push('/modules/virtual-files')
3435
}
3536
else{
36-
awaitrpc.openInEditor(filepath)
37+
awaitrpc.openInEditor(awaitensureDevAuthToken(),filepath)
3738
}
3839
}
3940
}

‎packages/devtools/client/composables/storage-options.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { NuxtDevToolsOptions } from '../../types'
33
import{watchDebounced}from'@vueuse/core'
44
import{reactive,toRefs}from'vue'
55
import{defaultTabOptions}from'../../src/constant'
6+
import{devAuthToken,isDevAuthed}from'./dev-auth'
67
import{rpc}from'./rpc'
78

89
constcache=newMap<string,any>()
@@ -22,7 +23,12 @@ function getTabOptions<T extends keyof NuxtDevToolsOptions>(tab: T): ToRefs<Nuxt
2223
watchDebounced(
2324
source,
2425
async(options)=>{
25-
rpc.updateOptions(tab,options)
26+
// Persisting options writes to disk and is a token-gated action.
27+
// Only persist when the session is already authenticated, and never
28+
// trigger an auth prompt from this passive watcher.
29+
if(!isDevAuthed.value||!devAuthToken.value)
30+
return
31+
rpc.updateOptions(devAuthToken.value,tab,options)
2632
},
2733
{deep: true,flush: 'post',debounce: 500,maxWait: 1000},
2834
)

‎packages/devtools/client/pages/settings.vue‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { watchEffect } from'vue'
33
import { definePageMeta } from'#imports'
44
import { useClient } from'~/composables/client'
5+
import { ensureDevAuthToken, isDevAuthed } from'~/composables/dev-auth'
56
import { rpc } from'~/composables/rpc'
67
import { getCategorizedTabs, useAllTabs } from'~/composables/state-tabs'
78
import { telemetryEnabled } from'~/composables/telemetry'
@@ -62,28 +63,48 @@ const MinimizeInactiveOptions = [
6263
6364
const categories =getCategorizedTabs(useAllTabs())
6465
66+
// Persisting settings is a token-gated action (see storage-options watcher).
67+
// When the user deliberately edits a setting on this page, ensure the session
68+
// is authenticated once so the change is actually written to disk.
69+
let authEnsured =false
70+
asyncfunction onEdit() {
71+
if (authEnsured||isDevAuthed.value)
72+
return
73+
authEnsured=true
74+
try {
75+
awaitensureDevAuthToken()
76+
}
77+
catch {
78+
authEnsured=false
79+
}
80+
}
81+
6582
function toggleTab(name:string, v?:boolean) {
83+
onEdit()
6684
if (v)
6785
hiddenTabs.value=hiddenTabs.value.filter(i=>i!==name)
6886
else
6987
hiddenTabs.value.push(name)
7088
}
7189
7290
function toggleTabCategory(name:string, v?:boolean) {
91+
onEdit()
7392
if (v)
7493
hiddenTabCategories.value=hiddenTabCategories.value.filter(i=>i!==name)
7594
else
7695
hiddenTabCategories.value.push(name)
7796
}
7897
7998
function togglePinTab(name:string) {
99+
onEdit()
80100
if (pinnedTabs.value.includes(name))
81101
pinnedTabs.value=pinnedTabs.value.filter(i=>i!==name)
82102
else
83103
pinnedTabs.value.push(name)
84104
}
85105
86106
function pinMove(name:string, delta:number) {
107+
onEdit()
87108
const index =pinnedTabs.value.indexOf(name)
88109
if (index===-1)
89110
return
@@ -105,7 +126,7 @@ async function clearOptions() {
105126
if (key.startsWith('nuxt-devtools-'))
106127
localStorage.removeItem(key)
107128
})
108-
awaitrpc.clearOptions()
129+
awaitrpc.clearOptions(awaitensureDevAuthToken())
109130
client.value?.app?.reload?.()
110131
window.location.reload()
111132
}
@@ -200,18 +221,18 @@ watchEffect(() => {
200221
</div>
201222
<divmx--2my1h-1pxborder="b base"op75 />
202223
<p>UI Scale</p>
203-
<NSelectv-model="scale" n="primary">
224+
<NSelectv-model="scale" n="primary" @update:model-value="onEdit">
204225
<optionv-for="i of scaleOptions":key="i[0]":value="i[1]">
205226
{{ i[0] }}
206227
</option>
207228
</NSelect>
208229
<divmx--2my1h-1pxborder="b base"op75 />
209-
<NCheckboxv-model="sidebarExpanded" n-primary>
230+
<NCheckboxv-model="sidebarExpanded" n-primary @update:model-value="onEdit">
210231
<span>
211232
Expand Sidebar
212233
</span>
213234
</NCheckbox>
214-
<NCheckboxv-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary>
235+
<NCheckboxv-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary @update:model-value="onEdit">
215236
<span>
216237
Scrollable Sidebar
217238
</span>
@@ -222,24 +243,24 @@ watchEffect(() => {
222243
Features
223244
</h3>
224245
<NCard p4 flex="~ col gap-2">
225-
<NCheckboxv-model="interactionCloseOnOutsideClick" n-primary>
246+
<NCheckboxv-model="interactionCloseOnOutsideClick" n-primary @update:model-value="onEdit">
226247
<span>Close DevTools when clicking outside</span>
227248
</NCheckbox>
228249
<!-- <NCheckbox v-model="showExperimentalFeatures" n-primary>
229250
<span>Show experimental features</span>
230251
</NCheckbox> -->
231-
<NCheckboxv-model="showHelpButtons" n-primary>
252+
<NCheckboxv-model="showHelpButtons" n-primary @update:model-value="onEdit">
232253
<span>Show help buttons</span>
233254
</NCheckbox>
234255

235-
<NCheckboxv-model="showPanel" n-primary>
256+
<NCheckboxv-model="showPanel" n-primary @update:model-value="onEdit">
236257
<span>Show the floating panel</span>
237258
</NCheckbox>
238259

239260
<divmx--2my1h-1pxborder="b base"op75 />
240261

241262
<p>Minimize floating panel on inactive</p>
242-
<NSelectv-model.number="minimizePanelInactive" n-primary>
263+
<NSelectv-model.number="minimizePanelInactive" n-primary @update:model-value="onEdit">
243264
<optionv-for="i of MinimizeInactiveOptions":key="i[0]":value="i[1]">
244265
{{ i[0] }}
245266
</option>
@@ -248,7 +269,7 @@ watchEffect(() => {
248269
<divmx--2my1h-1pxborder="b base"op75 />
249270

250271
<p>Open In Editor</p>
251-
<NSelectv-model="openInEditor" n-primary>
272+
<NSelectv-model="openInEditor" n-primary @update:model-value="onEdit">
252273
<optionv-for="i of editorOptions":key="i[0]":value="i[1]">
253274
{{ i[0] }}
254275
</option>

‎packages/devtools/client/plugins/global.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { NuxtDevtoolsHostClient } from '@nuxt/devtools-kit/types'
22
import{triggerRef}from'vue'
33
import{defineNuxtPlugin,useRouter}from'#imports'
44
import{useClient}from'../composables/client'
5+
import{ensureDevAuthToken}from'../composables/dev-auth'
56
import{rpc}from'../composables/rpc'
67

78
exportdefaultdefineNuxtPlugin(()=>{
@@ -13,8 +14,8 @@ export default defineNuxtPlugin(() => {
1314
client.value.revision.value+=1
1415
}
1516

16-
functiononInspectorClick(path: string){
17-
rpc.openInEditor(path)
17+
asyncfunctiononInspectorClick(path: string){
18+
rpc.openInEditor(awaitensureDevAuthToken(),path)
1819
}
1920

2021
functionsetupClient(_client: NuxtDevtoolsHostClient){

‎packages/devtools/package.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"dev:playground": "pnpm build && nuxi dev playground",
4444
"dev:prepare": "pnpm run stub && nuxi prepare client",
4545
"prepare": "tsx scripts/prepare.ts",
46-
"prepack": "pnpm build"
46+
"prepack": "pnpm build",
47+
"test:unit": "vitest run"
4748
},
4849
"peerDependencies": {
4950
"@vitejs/devtools": "*",
@@ -139,6 +140,7 @@
139140
"vanilla-jsoneditor": "catalog:frontend",
140141
"vis-data": "catalog:frontend",
141142
"vis-network": "catalog:frontend",
143+
"vitest": "catalog:cli",
142144
"vue-tsc": "catalog:cli",
143145
"vue-virtual-scroller": "catalog:frontend"
144146
}

‎packages/devtools/src/server-rpc/general.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
importtype{ModuleOptions,NuxtLayout}from'@nuxt/schema'
2-
importtype{Component,NuxtApp,NuxtPage}from'nuxt/schema'
2+
importtype{Component,NuxtApp,NuxtOptions,NuxtPage}from'nuxt/schema'
33
importtype{Import,Unimport}from'unimport'
44
importtype{AutoImportsWithMetadata,HookInfo,NuxtDevtoolsServerContext,ServerDebugContext,ServerFunctions}from'../types'
55
import{existsSync}from'node:fs'
@@ -86,8 +86,8 @@ export function setupGeneralRPC({
8686
})
8787

8888
return{
89-
getServerConfig(){
90-
returnnuxt.options
89+
getServerConfig(): NuxtOptions{
90+
returnnuxt.optionsasunknownasNuxtOptions
9191
},
9292
asyncgetServerDebugContext(){
9393
if(!nuxt._debug)
@@ -191,7 +191,8 @@ export function setupGeneralRPC({
191191
getServerHooks(): HookInfo[]{
192192
returnObject.values(serverHooks)
193193
},
194-
asyncopenInEditor(input: string): Promise<boolean>{
194+
asyncopenInEditor(token: string,input: string): Promise<boolean>{
195+
awaitensureDevAuthToken(token)
195196
if(input.startsWith('./')||!ABSOLUTE_PATH_RE.test(input))
196197
input=resolve(process.cwd(),input)
197198

‎packages/devtools/src/server-rpc/options.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export function getOptions() {
88
returnoptions
99
}
1010

11-
exportfunctionsetupOptionsRPC({ nuxt }: NuxtDevtoolsServerContext){
11+
exportfunctionsetupOptionsRPC({ nuxt, ensureDevAuthToken}: NuxtDevtoolsServerContext){
1212
asyncfunctiongetOptions<TextendskeyofNuxtDevToolsOptions>(tab: T): Promise<NuxtDevToolsOptions[T]>{
1313
if(!options||options[tab]){
1414
options=defaultTabOptions
@@ -28,15 +28,17 @@ export function setupOptionsRPC({ nuxt }: NuxtDevtoolsServerContext) {
2828

2929
getOptions('ui')
3030

31-
asyncfunctionclearOptions(){
31+
asyncfunctionclearOptions(token: string){
32+
awaitensureDevAuthToken(token)
3233
options=undefined
3334
awaitclearLocalOptions({
3435
root: nuxt.options.rootDir,
3536
})
3637
}
3738

3839
return{
39-
asyncupdateOptions(tab,_settings){
40+
asyncupdateOptions(token,tab,_settings){
41+
awaitensureDevAuthToken(token)
4042
constsettings=awaitgetOptions(tab)
4143
Object.assign(settings,_settings)
4244
awaitwriteLocalOptions(
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import{mkdtemp}from'node:fs/promises'
2+
import{tmpdir}from'node:os'
3+
import{join}from'node:path'
4+
import{createHooks}from'hookable'
5+
import{beforeEach,describe,expect,it,vi}from'vitest'
6+
import{setupGeneralRPC}from'../src/server-rpc/general'
7+
import{setupOptionsRPC}from'../src/server-rpc/options'
8+
9+
constVALID_TOKEN='valid-token'
10+
11+
// Mutating RPC methods must gate on the dev auth token, exactly like the rest
12+
// of the RPC surface (restartNuxt, storage writes, npm, terminals, ...). These
13+
// tests assert the guard runs before any side effect.
14+
functioncreateAuthStub(){
15+
returnvi.fn(async(token: string)=>{
16+
if(token!==VALID_TOKEN)
17+
thrownewError('[Nuxt DevTools] Invalid dev auth token.')
18+
})
19+
}
20+
21+
functioncreateNuxtStub(rootDir: string){
22+
consthooks=createHooks()
23+
return{
24+
hooks,
25+
hook: hooks.hook.bind(hooks),
26+
callHook: hooks.callHook.bind(hooks),
27+
options: { rootDir },
28+
}asany
29+
}
30+
31+
describe('options rpc auth',()=>{
32+
letrootDir: string
33+
34+
beforeEach(async()=>{
35+
rootDir=awaitmkdtemp(join(tmpdir(),'nuxt-devtools-test-'))
36+
// sandbox where local options are persisted (see getHomeDir)
37+
process.env.XDG_CONFIG_HOME=rootDir
38+
})
39+
40+
it('updateOptions rejects an invalid token',async()=>{
41+
constensureDevAuthToken=createAuthStub()
42+
constrpc=setupOptionsRPC({nuxt: createNuxtStub(rootDir), ensureDevAuthToken }asany)
43+
44+
awaitexpect(rpc.updateOptions('invalid','behavior',{openInEditor: 'malicious'}))
45+
.rejects
46+
.toThrow(/invaliddevauthtoken/i)
47+
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
48+
})
49+
50+
it('updateOptions proceeds with a valid token',async()=>{
51+
constensureDevAuthToken=createAuthStub()
52+
constrpc=setupOptionsRPC({nuxt: createNuxtStub(rootDir), ensureDevAuthToken }asany)
53+
54+
awaitexpect(rpc.updateOptions(VALID_TOKEN,'ui',{scale: 2})).resolves.toBeUndefined()
55+
expect(ensureDevAuthToken).toHaveBeenCalledWith(VALID_TOKEN)
56+
})
57+
58+
it('clearOptions rejects an invalid token',async()=>{
59+
constensureDevAuthToken=createAuthStub()
60+
constrpc=setupOptionsRPC({nuxt: createNuxtStub(rootDir), ensureDevAuthToken }asany)
61+
62+
awaitexpect(rpc.clearOptions('invalid')).rejects.toThrow(/invaliddevauthtoken/i)
63+
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
64+
})
65+
})
66+
67+
describe('general rpc auth',()=>{
68+
functioncreateGeneralRPC(ensureDevAuthToken: ReturnType<typeofcreateAuthStub>){
69+
returnsetupGeneralRPC({
70+
nuxt: createNuxtStub(process.cwd()),
71+
options: {},
72+
refresh: vi.fn(),
73+
ensureDevAuthToken,
74+
openInEditorHooks: [],
75+
}asany)
76+
}
77+
78+
it('openInEditor rejects an invalid token before touching the filesystem',async()=>{
79+
constensureDevAuthToken=createAuthStub()
80+
constgeneral=createGeneralRPC(ensureDevAuthToken)
81+
82+
awaitexpect(general.openInEditor('invalid','./some-file.ts'))
83+
.rejects
84+
.toThrow(/invaliddevauthtoken/i)
85+
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
86+
})
87+
88+
it('openInEditor proceeds past auth with a valid token',async()=>{
89+
constensureDevAuthToken=createAuthStub()
90+
constgeneral=createGeneralRPC(ensureDevAuthToken)
91+
92+
// A non-existent file resolves to `false` (never spawns an editor), which
93+
// proves the auth guard passed rather than short-circuiting with a throw.
94+
awaitexpect(general.openInEditor(VALID_TOKEN,'/definitely/not/a/real/path-xyz'))
95+
.resolves
96+
.toBe(false)
97+
expect(ensureDevAuthToken).toHaveBeenCalledWith(VALID_TOKEN)
98+
})
99+
})

0 commit comments

Comments
 (0)