Skip to content

Commit 7f072dc

Browse files
authored
fix(devtools): continue hardening the RPC surface (#1049)
1 parent 9a2da33 commit 7f072dc

15 files changed

Lines changed: 178 additions & 30 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"esbuild": "catalog:buildtools",
6666
"rollup": "catalog:buildtools",
6767
"semver": "catalog:prod",
68+
"structured-clone-es": "catalog:frontend",
6869
"typescript": "catalog:cli",
6970
"unimport": "catalog:types",
7071
"vite": "catalog:buildtools",

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface ServerFunctions {
1515
getServerConfig: () => NuxtOptions
1616
getServerDebugContext: () => Promise<ServerDebugContext | undefined>
1717
getServerData: (token: string) => Promise<NuxtServerData>
18-
getServerRuntimeConfig: () => Record<string, any>
18+
getServerRuntimeConfig: (token: string) => Promise<Record<string, any>>
1919
getModuleOptions: () => ModuleOptions
2020
getComponents: () => Component[]
2121
getComponentsRelationships: () => Promise<ComponentRelationship[]>
@@ -66,16 +66,16 @@ export interface ServerFunctions {
6666

6767
// Actions
6868
telemetryEvent: (payload: object, immediate?: boolean) => void
69-
customTabAction: (name: string, action: number) => Promise<boolean>
69+
customTabAction: (token: string, name: string, action: number) => Promise<boolean>
7070
runWizard: <T extends WizardActions>(token: string, name: T, ...args: GetWizardArgs<T>) => Promise<void>
7171
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>
75-
enableTimeline: (dry: boolean) => Promise<[string, string]>
75+
enableTimeline: (token: string, dry: boolean) => Promise<[string, string]>
7676

7777
// Dev Token
78-
requestForAuth: (info?: string, origin?: string) => Promise<void>
78+
requestForAuth: (info?: string) => Promise<void>
7979
verifyAuthToken: (token: string) => Promise<boolean>
8080
}
8181

‎packages/devtools/client/composables/dev-auth.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export async function requestForAuth() {
6464
userAgentInfo.os.version,
6565
userAgentInfo.device.type,
6666
].filter(i => i).join(' ')
67-
return await rpc.requestForAuth(desc, window.location.origin)
67+
return await rpc.requestForAuth(desc)
6868
}
6969

7070
async function authConfirmAction() {

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { objectPick } from '@antfu/utils'
44
import { computed } from 'vue'
55
import { useFetch } from '#app/composables/fetch'
66
import { useClientRouter } from './client'
7+
import { ensureDevAuthToken } from './dev-auth'
78
import { rpc } from './rpc'
89
import { useAsyncState } from './utils'
910

@@ -44,7 +45,7 @@ export function useServerDebugContext() {
4445
}
4546

4647
export function useServerRuntimeConfig() {
47-
return useAsyncState('getServerRuntimeConfig', () => rpc.getServerRuntimeConfig())
48+
return useAsyncState('getServerRuntimeConfig', async () => rpc.getServerRuntimeConfig(await ensureDevAuthToken()))
4849
}
4950

5051
export function useModuleOptions() {

‎packages/devtools/client/pages/modules/custom-[name].vue‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ModuleCustomTab } from '~/../src/types'
33
import { computed, onMounted } from 'vue'
44
import { useRoute, useRouter } from '#app/composables/router'
55
import { definePageMeta } from '#imports'
6-
import { isDevAuthed, requestForAuth } from '~/composables/dev-auth'
6+
import { ensureDevAuthToken, isDevAuthed, requestForAuth } from '~/composables/dev-auth'
77
import { rpc } from '~/composables/rpc'
88
import { useAllTabs } from '~/composables/state-tabs'
99
@@ -68,7 +68,7 @@ onMounted(() => {
6868
:title="tab.view.title || tab.title"
6969
:description="tab.view.description"
7070
:actions="tab.view.actions"
71-
@action="idx => rpc.customTabAction(tab!.name, idx)"
71+
@action="async idx => rpc.customTabAction(await ensureDevAuthToken(), tab!.name, idx)"
7272
/>
7373
</template>
7474
<template v-else>

‎packages/devtools/client/pages/modules/timeline.vue‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import { createTemplatePromise } from '@vueuse/core'
33
import { definePageMeta, devtoolsUiShowNotification } from '#imports'
4+
import { ensureDevAuthToken } from '~/composables/dev-auth'
45
import { useOpenInEditor } from '~/composables/editor'
56
import { rpc } from '~/composables/rpc'
67
import { useModuleOptions, useServerConfig } from '~/composables/state'
@@ -20,10 +21,10 @@ const openInEditor = useOpenInEditor()
2021
2122
async function showPopup() {
2223
try {
23-
const [source, modified] = await rpc.enableTimeline(true)
24+
const [source, modified] = await rpc.enableTimeline(await ensureDevAuthToken(), true)
2425
if (!await Dialog.start(source, modified))
2526
return
26-
await rpc.enableTimeline(false)
27+
await rpc.enableTimeline(await ensureDevAuthToken(), false)
2728
}
2829
catch {
2930
devtoolsUiShowNotification({

‎packages/devtools/src/server-rpc/custom-tabs.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { ModuleCustomTab, NuxtDevtoolsServerContext, ServerFunctions } from '../types'
22

3-
export function setupCustomTabRPC({ nuxt, options, refresh }: NuxtDevtoolsServerContext) {
3+
export function setupCustomTabRPC({ nuxt, options, refresh, ensureDevAuthToken }: NuxtDevtoolsServerContext) {
44
const iframeTabs: ModuleCustomTab[] = []
55
const customTabs: ModuleCustomTab[] = []
66

@@ -35,7 +35,8 @@ export function setupCustomTabRPC({ nuxt, options, refresh }: NuxtDevtoolsServer
3535
return i
3636
})
3737
},
38-
async customTabAction(name, actionIndex) {
38+
async customTabAction(token, name, actionIndex) {
39+
await ensureDevAuthToken(token)
3940
const tab = customTabs.find(i => i.name === name)
4041
if (!tab)
4142
return false

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

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,19 @@ export function setupGeneralRPC({
8787

8888
return {
8989
getServerConfig(): NuxtOptions {
90-
return nuxt.options as unknown as NuxtOptions
90+
// This method is intentionally reachable without a dev auth token (the
91+
// client reads it eagerly across many views), so strip the private
92+
// `runtimeConfig` values — they can hold secrets — from the payload.
93+
// The Runtime Config tab reads them separately via the token-gated
94+
// `getServerRuntimeConfig`. `public`/`app` are non-secret and kept.
95+
const { runtimeConfig, ...rest } = nuxt.options
96+
return {
97+
...rest,
98+
runtimeConfig: {
99+
app: runtimeConfig?.app,
100+
public: runtimeConfig?.public,
101+
},
102+
} as unknown as NuxtOptions
91103
},
92104
async getServerDebugContext() {
93105
if (!nuxt._debug)
@@ -124,7 +136,10 @@ export function setupGeneralRPC({
124136
),
125137
}
126138
},
127-
getServerRuntimeConfig(): Record<string, any> {
139+
async getServerRuntimeConfig(token: string): Promise<Record<string, any>> {
140+
// Exposes runtimeConfig merged with env vars, which can hold secrets, so
141+
// require the dev auth token.
142+
await ensureDevAuthToken(token)
128143
// Ported from https://github.com/unjs/nitro/blob/88e79fcdb2a024c96a3d1fd272d0acbff0405013/src/runtime/config.ts#L31
129144
// Since this operation happends on the Nitro runtime
130145
const ENV_PREFIX = 'NITRO_'
@@ -239,13 +254,17 @@ export function setupGeneralRPC({
239254
logger.info('Restarting Nuxt...')
240255
return nuxt.callHook('restart', { hard })
241256
},
242-
async requestForAuth(info, origin?) {
257+
async requestForAuth(info) {
243258
if (options.disableAuthorization)
244259
return
245260

246261
const token = await getDevAuthToken()
247262

248-
origin ||= `${nuxt.options.devServer.https ? 'https' : 'http'}://${nuxt.options.devServer.host === '::' ? 'localhost' : (nuxt.options.devServer.host || 'localhost')}:${nuxt.options.devServer.port}`
263+
// Always derive the origin from the dev server config. Never trust a
264+
// client-supplied origin here: it is printed next to the real auth token
265+
// in an "open this URL" instruction, so an attacker-controlled origin
266+
// would turn this into a token-exfiltration lure.
267+
const origin = `${nuxt.options.devServer.https ? 'https' : 'http'}://${nuxt.options.devServer.host === '::' ? 'localhost' : (nuxt.options.devServer.host || 'localhost')}:${nuxt.options.devServer.port}`
249268

250269
const ROUTE_AUTH = `${nuxt.options.app.baseURL || '/'}/__nuxt_devtools__/auth`.replace(MULTIPLE_SLASHES_RE, '/')
251270

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ChannelOptions } from 'birpc'
2+
import type { IncomingMessage } from 'node:http'
23
import type { Nuxt } from 'nuxt/schema'
34
import type { Plugin } from 'vite'
45

@@ -10,6 +11,7 @@ import { colors } from 'consola/utils'
1011
import { parse, stringify } from 'structured-clone-es'
1112
import { WS_EVENT_NAME } from '../constant'
1213
import { getDevAuthToken } from '../dev-auth'
14+
import { isAllowedRpcOrigin } from '../utils/rpc-origin'
1315
import { setupAnalyzeBuildRPC } from './analyze-build'
1416
import { setupAssetsRPC } from './assets'
1517
import { setupCustomTabRPC } from './custom-tabs'
@@ -48,6 +50,16 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) {
4850
+ colors.red(error?.message || ''),
4951
)
5052
},
53+
// A malformed or hostile frame over the (unauthenticated) HMR socket can
54+
// make deserialization throw. Swallow it here so a bad frame is dropped
55+
// rather than surfacing as an unhandled rejection that crashes dev.
56+
onGeneralError(error) {
57+
logger.error(
58+
colors.yellow('[nuxt-devtools] RPC channel error:\n')
59+
+ colors.red((error as Error)?.message || String(error)),
60+
)
61+
return true
62+
},
5163
timeout: 120_000,
5264
},
5365
)
@@ -110,7 +122,17 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) {
110122
const vitePlugin: Plugin = {
111123
name: 'nuxt:devtools:rpc',
112124
configureServer(server) {
113-
server.ws.on('connection', (ws) => {
125+
server.ws.on('connection', (ws: WebSocket, request: IncomingMessage) => {
126+
// Cross-site WebSocket hijacking guard: a browser page on another
127+
// origin can reach the (origin-less) Vite HMR socket. Only same-origin
128+
// browser connections may open a DevTools RPC channel. We leave the
129+
// HMR socket itself untouched so normal HMR keeps working.
130+
if (!isAllowedRpcOrigin(request?.headers?.origin, request?.headers?.host)) {
131+
logger.warn(
132+
colors.yellow(`[nuxt-devtools] Ignored a cross-origin RPC connection from origin "${request?.headers?.origin}".`),
133+
)
134+
return
135+
}
114136
wsClients.add(ws)
115137
const channel: ChannelOptions = {
116138
post: d => ws.send(JSON.stringify({

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import { parseModule } from 'magicast'
44
import { getDefaultExportOptions } from 'magicast/helpers'
55
import { magicastGuard } from '../utils/magicast'
66

7-
export function setupTimelineRPC({ nuxt }: NuxtDevtoolsServerContext) {
7+
export function setupTimelineRPC({ nuxt, ensureDevAuthToken }: NuxtDevtoolsServerContext) {
88
return {
9-
async enableTimeline(dry: boolean) {
9+
async enableTimeline(token: string, dry: boolean) {
10+
await ensureDevAuthToken(token)
1011
const filepath = nuxt.options._nuxtConfigFile
1112
const source = await fs.readFile(filepath, 'utf-8')
1213
const generated = await magicastGuard(async () => {

0 commit comments

Comments
 (0)