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: <TextendsWizardActions>(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-
returnawaitrpc.requestForAuth(desc,window.location.origin)
67+
returnawaitrpc.requestForAuth(desc)
6868
}
6969

7070
asyncfunctionauthConfirmAction(){

‎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
exportfunctionuseServerRuntimeConfig(){
47-
returnuseAsyncState('getServerRuntimeConfig',()=>rpc.getServerRuntimeConfig())
48+
returnuseAsyncState('getServerRuntimeConfig',async()=>rpc.getServerRuntimeConfig(awaitensureDevAuthToken()))
4849
}
4950

5051
exportfunctionuseModuleOptions(){

‎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="asyncidx=>rpc.customTabAction(awaitensureDevAuthToken(), tab!.name, idx)"
7272
/>
7373
</template>
7474
<templatev-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
asyncfunction showPopup() {
2223
try {
23-
const [source, modified] =awaitrpc.enableTimeline(true)
24+
const [source, modified] =awaitrpc.enableTimeline(awaitensureDevAuthToken(), true)
2425
if (!awaitDialog.start(source, modified))
2526
return
26-
awaitrpc.enableTimeline(false)
27+
awaitrpc.enableTimeline(awaitensureDevAuthToken(), 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
importtype{ModuleCustomTab,NuxtDevtoolsServerContext,ServerFunctions}from'../types'
22

3-
exportfunctionsetupCustomTabRPC({ nuxt, options, refresh }: NuxtDevtoolsServerContext){
3+
exportfunctionsetupCustomTabRPC({ nuxt, options, refresh, ensureDevAuthToken}: NuxtDevtoolsServerContext){
44
constiframeTabs: ModuleCustomTab[]=[]
55
constcustomTabs: ModuleCustomTab[]=[]
66

@@ -35,7 +35,8 @@ export function setupCustomTabRPC({ nuxt, options, refresh }: NuxtDevtoolsServer
3535
returni
3636
})
3737
},
38-
asynccustomTabAction(name,actionIndex){
38+
asynccustomTabAction(token,name,actionIndex){
39+
awaitensureDevAuthToken(token)
3940
consttab=customTabs.find(i=>i.name===name)
4041
if(!tab)
4142
returnfalse

‎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-
returnnuxt.optionsasunknownasNuxtOptions
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+
}asunknownasNuxtOptions
91103
},
92104
asyncgetServerDebugContext(){
93105
if(!nuxt._debug)
@@ -124,7 +136,10 @@ export function setupGeneralRPC({
124136
),
125137
}
126138
},
127-
getServerRuntimeConfig(): Record<string,any>{
139+
asyncgetServerRuntimeConfig(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+
awaitensureDevAuthToken(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
constENV_PREFIX='NITRO_'
@@ -239,13 +254,17 @@ export function setupGeneralRPC({
239254
logger.info('Restarting Nuxt...')
240255
returnnuxt.callHook('restart',{ hard })
241256
},
242-
asyncrequestForAuth(info,origin?){
257+
asyncrequestForAuth(info){
243258
if(options.disableAuthorization)
244259
return
245260

246261
consttoken=awaitgetDevAuthToken()
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+
constorigin=`${nuxt.options.devServer.https ? 'https' : 'http'}://${nuxt.options.devServer.host==='::' ? 'localhost' : (nuxt.options.devServer.host||'localhost')}:${nuxt.options.devServer.port}`
249268

250269
constROUTE_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
importtype{ChannelOptions}from'birpc'
2+
importtype{IncomingMessage}from'node:http'
23
importtype{Nuxt}from'nuxt/schema'
34
importtype{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((errorasError)?.message||String(error)),
60+
)
61+
returntrue
62+
},
5163
timeout: 120_000,
5264
},
5365
)
@@ -110,7 +122,17 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) {
110122
constvitePlugin: 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
constchannel: 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-
exportfunctionsetupTimelineRPC({ nuxt }: NuxtDevtoolsServerContext){
7+
exportfunctionsetupTimelineRPC({ nuxt, ensureDevAuthToken}: NuxtDevtoolsServerContext){
88
return{
9-
asyncenableTimeline(dry: boolean){
9+
asyncenableTimeline(token: string,dry: boolean){
10+
awaitensureDevAuthToken(token)
1011
constfilepath=nuxt.options._nuxtConfigFile
1112
constsource=awaitfs.readFile(filepath,'utf-8')
1213
constgenerated=awaitmagicastGuard(async()=>{

0 commit comments

Comments
 (0)