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: <T extends keyof NuxtDevToolsOptions>(tab: T) => Promise<NuxtDevToolsOptions[T]>
34-
updateOptions: <T extends keyof NuxtDevToolsOptions>(tab: T, settings: Partial<NuxtDevToolsOptions[T]>) => Promise<void>
35-
clearOptions: () => Promise<void>
34+
updateOptions: <T extends keyof NuxtDevToolsOptions>(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: <T extends WizardActions>(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-
await rpc.openInEditor(filepath)
37+
await rpc.openInEditor(await ensureDevAuthToken(), 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
const cache = new Map<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+
async function onEdit() {
71+
if (authEnsured || isDevAuthed.value)
72+
return
73+
authEnsured = true
74+
try {
75+
await ensureDevAuthToken()
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-
await rpc.clearOptions()
129+
await rpc.clearOptions(await ensureDevAuthToken())
109130
client.value?.app?.reload?.()
110131
window.location.reload()
111132
}
@@ -200,18 +221,18 @@ watchEffect(() => {
200221
</div>
201222
<div mx--2 my1 h-1px border="b base" op75 />
202223
<p>UI Scale</p>
203-
<NSelect v-model="scale" n="primary">
224+
<NSelect v-model="scale" n="primary" @update:model-value="onEdit">
204225
<option v-for="i of scaleOptions" :key="i[0]" :value="i[1]">
205226
{{ i[0] }}
206227
</option>
207228
</NSelect>
208229
<div mx--2 my1 h-1px border="b base" op75 />
209-
<NCheckbox v-model="sidebarExpanded" n-primary>
230+
<NCheckbox v-model="sidebarExpanded" n-primary @update:model-value="onEdit">
210231
<span>
211232
Expand Sidebar
212233
</span>
213234
</NCheckbox>
214-
<NCheckbox v-model="sidebarScrollable" :disabled="sidebarExpanded" n-primary>
235+
<NCheckbox v-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-
<NCheckbox v-model="interactionCloseOnOutsideClick" n-primary>
246+
<NCheckbox v-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-
<NCheckbox v-model="showHelpButtons" n-primary>
252+
<NCheckbox v-model="showHelpButtons" n-primary @update:model-value="onEdit">
232253
<span>Show help buttons</span>
233254
</NCheckbox>
234255

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

239260
<div mx--2 my1 h-1px border="b base" op75 />
240261

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

250271
<p>Open In Editor</p>
251-
<NSelect v-model="openInEditor" n-primary>
272+
<NSelect v-model="openInEditor" n-primary @update:model-value="onEdit">
252273
<option v-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
export default defineNuxtPlugin(() => {
@@ -13,8 +14,8 @@ export default defineNuxtPlugin(() => {
1314
client.value.revision.value += 1
1415
}
1516

16-
function onInspectorClick(path: string) {
17-
rpc.openInEditor(path)
17+
async function onInspectorClick(path: string) {
18+
rpc.openInEditor(await ensureDevAuthToken(), path)
1819
}
1920

2021
function setupClient(_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
import type { ModuleOptions, NuxtLayout } from '@nuxt/schema'
2-
import type { Component, NuxtApp, NuxtPage } from 'nuxt/schema'
2+
import type { Component, NuxtApp, NuxtOptions, NuxtPage } from 'nuxt/schema'
33
import type { Import, Unimport } from 'unimport'
44
import type { 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-
return nuxt.options
89+
getServerConfig(): NuxtOptions {
90+
return nuxt.options as unknown as NuxtOptions
9191
},
9292
async getServerDebugContext() {
9393
if (!nuxt._debug)
@@ -191,7 +191,8 @@ export function setupGeneralRPC({
191191
getServerHooks(): HookInfo[] {
192192
return Object.values(serverHooks)
193193
},
194-
async openInEditor(input: string): Promise<boolean> {
194+
async openInEditor(token: string, input: string): Promise<boolean> {
195+
await ensureDevAuthToken(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
return options
99
}
1010

11-
export function setupOptionsRPC({ nuxt }: NuxtDevtoolsServerContext) {
11+
export function setupOptionsRPC({ nuxt, ensureDevAuthToken }: NuxtDevtoolsServerContext) {
1212
async function getOptions<T extends keyof NuxtDevToolsOptions>(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-
async function clearOptions() {
31+
async function clearOptions(token: string) {
32+
await ensureDevAuthToken(token)
3233
options = undefined
3334
await clearLocalOptions({
3435
root: nuxt.options.rootDir,
3536
})
3637
}
3738

3839
return {
39-
async updateOptions(tab, _settings) {
40+
async updateOptions(token, tab, _settings) {
41+
await ensureDevAuthToken(token)
4042
const settings = await getOptions(tab)
4143
Object.assign(settings, _settings)
4244
await writeLocalOptions(
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+
const VALID_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+
function createAuthStub() {
15+
return vi.fn(async (token: string) => {
16+
if (token !== VALID_TOKEN)
17+
throw new Error('[Nuxt DevTools] Invalid dev auth token.')
18+
})
19+
}
20+
21+
function createNuxtStub(rootDir: string) {
22+
const hooks = createHooks()
23+
return {
24+
hooks,
25+
hook: hooks.hook.bind(hooks),
26+
callHook: hooks.callHook.bind(hooks),
27+
options: { rootDir },
28+
} as any
29+
}
30+
31+
describe('options rpc auth', () => {
32+
let rootDir: string
33+
34+
beforeEach(async () => {
35+
rootDir = await mkdtemp(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+
const ensureDevAuthToken = createAuthStub()
42+
const rpc = setupOptionsRPC({ nuxt: createNuxtStub(rootDir), ensureDevAuthToken } as any)
43+
44+
await expect(rpc.updateOptions('invalid', 'behavior', { openInEditor: 'malicious' }))
45+
.rejects
46+
.toThrow(/invalid dev auth token/i)
47+
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
48+
})
49+
50+
it('updateOptions proceeds with a valid token', async () => {
51+
const ensureDevAuthToken = createAuthStub()
52+
const rpc = setupOptionsRPC({ nuxt: createNuxtStub(rootDir), ensureDevAuthToken } as any)
53+
54+
await expect(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+
const ensureDevAuthToken = createAuthStub()
60+
const rpc = setupOptionsRPC({ nuxt: createNuxtStub(rootDir), ensureDevAuthToken } as any)
61+
62+
await expect(rpc.clearOptions('invalid')).rejects.toThrow(/invalid dev auth token/i)
63+
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
64+
})
65+
})
66+
67+
describe('general rpc auth', () => {
68+
function createGeneralRPC(ensureDevAuthToken: ReturnType<typeof createAuthStub>) {
69+
return setupGeneralRPC({
70+
nuxt: createNuxtStub(process.cwd()),
71+
options: {},
72+
refresh: vi.fn(),
73+
ensureDevAuthToken,
74+
openInEditorHooks: [],
75+
} as any)
76+
}
77+
78+
it('openInEditor rejects an invalid token before touching the filesystem', async () => {
79+
const ensureDevAuthToken = createAuthStub()
80+
const general = createGeneralRPC(ensureDevAuthToken)
81+
82+
await expect(general.openInEditor('invalid', './some-file.ts'))
83+
.rejects
84+
.toThrow(/invalid dev auth token/i)
85+
expect(ensureDevAuthToken).toHaveBeenCalledWith('invalid')
86+
})
87+
88+
it('openInEditor proceeds past auth with a valid token', async () => {
89+
const ensureDevAuthToken = createAuthStub()
90+
const general = 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+
await expect(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)