Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"esbuild": "catalog:buildtools",
"rollup": "catalog:buildtools",
"semver": "catalog:prod",
"structured-clone-es": "catalog:frontend",
"typescript": "catalog:cli",
"unimport": "catalog:types",
"vite": "catalog:buildtools",
Expand Down
8 changes: 4 additions & 4 deletions packages/devtools-kit/src/_types/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface ServerFunctions {
getServerConfig: () => NuxtOptions
getServerDebugContext: () => Promise<ServerDebugContext | undefined>
getServerData: (token: string) => Promise<NuxtServerData>
getServerRuntimeConfig: () => Record<string, any>
getServerRuntimeConfig: (token: string) => Promise<Record<string, any>>
getModuleOptions: () => ModuleOptions
getComponents: () => Component[]
getComponentsRelationships: () => Promise<ComponentRelationship[]>
Expand Down Expand Up @@ -66,16 +66,16 @@ export interface ServerFunctions {

// Actions
telemetryEvent: (payload: object, immediate?: boolean) => void
customTabAction: (name: string, action: number) => Promise<boolean>
customTabAction: (token: string, name: string, action: number) => Promise<boolean>
runWizard: <T extends WizardActions>(token: string, name: T, ...args: GetWizardArgs<T>) => Promise<void>
openInEditor: (token: string, filepath: string) => Promise<boolean>
restartNuxt: (token: string, hard?: boolean) => Promise<void>
installNuxtModule: (token: string, name: string, dry?: boolean) => Promise<InstallModuleReturn>
uninstallNuxtModule: (token: string, name: string, dry?: boolean) => Promise<InstallModuleReturn>
enableTimeline: (dry: boolean) => Promise<[string, string]>
enableTimeline: (token: string, dry: boolean) => Promise<[string, string]>

// Dev Token
requestForAuth: (info?: string, origin?: string) => Promise<void>
requestForAuth: (info?: string) => Promise<void>
verifyAuthToken: (token: string) => Promise<boolean>
}

Expand Down
2 changes: 1 addition & 1 deletion packages/devtools/client/composables/dev-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export async function requestForAuth() {
userAgentInfo.os.version,
userAgentInfo.device.type,
].filter(i => i).join(' ')
return await rpc.requestForAuth(desc, window.location.origin)
return await rpc.requestForAuth(desc)
}

async function authConfirmAction() {
Expand Down
3 changes: 2 additions & 1 deletion packages/devtools/client/composables/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { objectPick } from '@antfu/utils'
import { computed } from 'vue'
import { useFetch } from '#app/composables/fetch'
import { useClientRouter } from './client'
import { ensureDevAuthToken } from './dev-auth'
import { rpc } from './rpc'
import { useAsyncState } from './utils'

Expand Down Expand Up @@ -44,7 +45,7 @@ export function useServerDebugContext() {
}

export function useServerRuntimeConfig() {
return useAsyncState('getServerRuntimeConfig', () => rpc.getServerRuntimeConfig())
return useAsyncState('getServerRuntimeConfig', async () => rpc.getServerRuntimeConfig(await ensureDevAuthToken()))
}

export function useModuleOptions() {
Expand Down
4 changes: 2 additions & 2 deletions packages/devtools/client/pages/modules/custom-[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ModuleCustomTab } from '~/../src/types'
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from '#app/composables/router'
import { definePageMeta } from '#imports'
import { isDevAuthed, requestForAuth } from '~/composables/dev-auth'
import { ensureDevAuthToken, isDevAuthed, requestForAuth } from '~/composables/dev-auth'
import { rpc } from '~/composables/rpc'
import { useAllTabs } from '~/composables/state-tabs'

Expand Down Expand Up @@ -68,7 +68,7 @@ onMounted(() => {
:title="tab.view.title || tab.title"
:description="tab.view.description"
:actions="tab.view.actions"
@action="idx => rpc.customTabAction(tab!.name, idx)"
@action="async idx => rpc.customTabAction(await ensureDevAuthToken(), tab!.name, idx)"
/>
</template>
<template v-else>
Expand Down
5 changes: 3 additions & 2 deletions packages/devtools/client/pages/modules/timeline.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { createTemplatePromise } from '@vueuse/core'
import { definePageMeta, devtoolsUiShowNotification } from '#imports'
import { ensureDevAuthToken } from '~/composables/dev-auth'
import { useOpenInEditor } from '~/composables/editor'
import { rpc } from '~/composables/rpc'
import { useModuleOptions, useServerConfig } from '~/composables/state'
Expand All @@ -20,10 +21,10 @@ const openInEditor = useOpenInEditor()

async function showPopup() {
try {
const [source, modified] = await rpc.enableTimeline(true)
const [source, modified] = await rpc.enableTimeline(await ensureDevAuthToken(), true)
if (!await Dialog.start(source, modified))
return
await rpc.enableTimeline(false)
await rpc.enableTimeline(await ensureDevAuthToken(), false)
}
catch {
devtoolsUiShowNotification({
Expand Down
5 changes: 3 additions & 2 deletions packages/devtools/src/server-rpc/custom-tabs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ModuleCustomTab, NuxtDevtoolsServerContext, ServerFunctions } from '../types'

export function setupCustomTabRPC({ nuxt, options, refresh }: NuxtDevtoolsServerContext) {
export function setupCustomTabRPC({ nuxt, options, refresh, ensureDevAuthToken }: NuxtDevtoolsServerContext) {
const iframeTabs: ModuleCustomTab[] = []
const customTabs: ModuleCustomTab[] = []

Expand Down Expand Up @@ -35,7 +35,8 @@ export function setupCustomTabRPC({ nuxt, options, refresh }: NuxtDevtoolsServer
return i
})
},
async customTabAction(name, actionIndex) {
async customTabAction(token, name, actionIndex) {
await ensureDevAuthToken(token)
const tab = customTabs.find(i => i.name === name)
if (!tab)
return false
Expand Down
27 changes: 23 additions & 4 deletions packages/devtools/src/server-rpc/general.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,19 @@ export function setupGeneralRPC({

return {
getServerConfig(): NuxtOptions {
return nuxt.options as unknown as NuxtOptions
// This method is intentionally reachable without a dev auth token (the
// client reads it eagerly across many views), so strip the private
// `runtimeConfig` values — they can hold secrets — from the payload.
// The Runtime Config tab reads them separately via the token-gated
// `getServerRuntimeConfig`. `public`/`app` are non-secret and kept.
const { runtimeConfig, ...rest } = nuxt.options
return {
...rest,
runtimeConfig: {
app: runtimeConfig?.app,
public: runtimeConfig?.public,
},
} as unknown as NuxtOptions
},
async getServerDebugContext() {
if (!nuxt._debug)
Expand Down Expand Up @@ -124,7 +136,10 @@ export function setupGeneralRPC({
),
}
},
getServerRuntimeConfig(): Record<string, any> {
async getServerRuntimeConfig(token: string): Promise<Record<string, any>> {
// Exposes runtimeConfig merged with env vars, which can hold secrets, so
// require the dev auth token.
await ensureDevAuthToken(token)
// Ported from https://github.com/unjs/nitro/blob/88e79fcdb2a024c96a3d1fd272d0acbff0405013/src/runtime/config.ts#L31
// Since this operation happends on the Nitro runtime
const ENV_PREFIX = 'NITRO_'
Expand Down Expand Up @@ -239,13 +254,17 @@ export function setupGeneralRPC({
logger.info('Restarting Nuxt...')
return nuxt.callHook('restart', { hard })
},
async requestForAuth(info, origin?) {
async requestForAuth(info) {
if (options.disableAuthorization)
return

const token = await getDevAuthToken()

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

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

Expand Down
24 changes: 23 additions & 1 deletion packages/devtools/src/server-rpc/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ChannelOptions } from 'birpc'
import type { IncomingMessage } from 'node:http'
import type { Nuxt } from 'nuxt/schema'
import type { Plugin } from 'vite'

Expand All @@ -10,6 +11,7 @@ import { colors } from 'consola/utils'
import { parse, stringify } from 'structured-clone-es'
import { WS_EVENT_NAME } from '../constant'
import { getDevAuthToken } from '../dev-auth'
import { isAllowedRpcOrigin } from '../utils/rpc-origin'
import { setupAnalyzeBuildRPC } from './analyze-build'
import { setupAssetsRPC } from './assets'
import { setupCustomTabRPC } from './custom-tabs'
Expand Down Expand Up @@ -48,6 +50,16 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) {
+ colors.red(error?.message || ''),
)
},
// A malformed or hostile frame over the (unauthenticated) HMR socket can
// make deserialization throw. Swallow it here so a bad frame is dropped
// rather than surfacing as an unhandled rejection that crashes dev.
onGeneralError(error) {
logger.error(
colors.yellow('[nuxt-devtools] RPC channel error:\n')
+ colors.red((error as Error)?.message || String(error)),
)
return true
},
timeout: 120_000,
},
)
Expand Down Expand Up @@ -110,7 +122,17 @@ export function setupRPC(nuxt: Nuxt, options: ModuleOptions) {
const vitePlugin: Plugin = {
name: 'nuxt:devtools:rpc',
configureServer(server) {
server.ws.on('connection', (ws) => {
server.ws.on('connection', (ws: WebSocket, request: IncomingMessage) => {
// Cross-site WebSocket hijacking guard: a browser page on another
// origin can reach the (origin-less) Vite HMR socket. Only same-origin
// browser connections may open a DevTools RPC channel. We leave the
// HMR socket itself untouched so normal HMR keeps working.
if (!isAllowedRpcOrigin(request?.headers?.origin, request?.headers?.host)) {
logger.warn(
colors.yellow(`[nuxt-devtools] Ignored a cross-origin RPC connection from origin "${request?.headers?.origin}".`),
)
return
}
wsClients.add(ws)
const channel: ChannelOptions = {
post: d => ws.send(JSON.stringify({
Expand Down
5 changes: 3 additions & 2 deletions packages/devtools/src/server-rpc/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { parseModule } from 'magicast'
import { getDefaultExportOptions } from 'magicast/helpers'
import { magicastGuard } from '../utils/magicast'

export function setupTimelineRPC({ nuxt }: NuxtDevtoolsServerContext) {
export function setupTimelineRPC({ nuxt, ensureDevAuthToken }: NuxtDevtoolsServerContext) {
return {
async enableTimeline(dry: boolean) {
async enableTimeline(token: string, dry: boolean) {
await ensureDevAuthToken(token)
const filepath = nuxt.options._nuxtConfigFile
const source = await fs.readFile(filepath, 'utf-8')
const generated = await magicastGuard(async () => {
Expand Down
36 changes: 36 additions & 0 deletions packages/devtools/src/utils/rpc-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Decide whether an incoming RPC WebSocket connection may open a DevTools RPC
* channel, based on its `Origin` / `Host` headers.
*
* The RPC runs over the Vite HMR socket, which has no origin check, so any site
* a developer visits can connect to `ws://localhost:<port>/_nuxt/` and drive
* the RPC from their browser (cross-site WebSocket hijacking). Browsers always
* send an `Origin` on the WS handshake and it cannot be forged from page JS, so
* requiring the origin to be same-origin with the dev server (its `Host`)
* blocks that vector.
*
* A missing `Origin` means a non-browser client (Node HMR client, tooling,
* tests) — those are a local/network actor, which is handled by the per-method
* dev auth token rather than here, so they are allowed through.
*/
export function isAllowedRpcOrigin(
origin: string | undefined | null,
host: string | undefined | null,
): boolean {
// Non-browser client (no Origin header) — not a CSWSH vector.
if (!origin)
return true

let originHost: string
try {
originHost = new URL(origin).host
}
catch {
// Malformed Origin — reject.
return false
}

// Same-origin: the DevTools client is served by the dev server itself, so its
// Origin host matches the request Host. A page on any other site will not.
return !!host && originHost === host
}
38 changes: 38 additions & 0 deletions packages/devtools/test/rpc-deserialize-safety.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { parse, stringify } from 'structured-clone-es'
import { describe, expect, it } from 'vitest'

// The RPC channel deserializes untrusted frames from the Vite HMR WebSocket
// with structured-clone-es. Before 2.0.1 its type-7 branch ran
// `new globalThis[name](message)`, so a crafted frame could deserialize into a
// real `new Function("<attacker body>")` — a code-execution primitive once
// birpc assimilated it (await on a return value / resolve on a response).
// 2.0.1 allowlists constructors, which is the fix this suite pins in place.
describe('structured-clone-es deserialization is hardened (>=2.0.1)', () => {
it('never constructs a callable from a type-7 "Function" payload', () => {
const records = [[7, { name: 'Function', message: 'return globalThis' }]]
const result = parse(JSON.stringify(records))
expect(typeof result).not.toBe('function')
// unsafe constructor names fall back to a plain Error
expect(result).toBeInstanceOf(Error)
})

it('never constructs a callable smuggled as a `then` (thenable assimilation)', () => {
const records = [
[2, [[1, 2]]], // { then: <value> }
[0, 'then'],
[7, { name: 'Function', message: 'globalThis.__pwned = true' }],
]
const result = parse(JSON.stringify(records)) as { then?: unknown }
expect(typeof result.then).not.toBe('function')
})

it('rejects unknown/unsafe constructor types instead of instantiating them', () => {
const records = [['Function', 'return globalThis']]
expect(() => parse(JSON.stringify(records))).toThrow(/unsafe or unknown type/i)
})

it('still round-trips legitimate RPC payloads', () => {
const message = { t: 'q', m: 'getOptions', a: ['ui'], i: 'abc' }
expect(parse(stringify(message))).toEqual(message)
})
})
30 changes: 30 additions & 0 deletions packages/devtools/test/rpc-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { isAllowedRpcOrigin } from '../src/utils/rpc-origin'

describe('isAllowedRpcOrigin (cross-site WebSocket hijacking guard)', () => {
it('allows same-origin browser connections', () => {
expect(isAllowedRpcOrigin('http://localhost:3000', 'localhost:3000')).toBe(true)
expect(isAllowedRpcOrigin('http://127.0.0.1:3000', '127.0.0.1:3000')).toBe(true)
expect(isAllowedRpcOrigin('https://my-app.test', 'my-app.test')).toBe(true)
})

it('rejects cross-origin browser connections (the CSWSH vector)', () => {
expect(isAllowedRpcOrigin('https://evil.com', 'localhost:3000')).toBe(false)
// same host, different port is still a different origin
expect(isAllowedRpcOrigin('http://localhost:5173', 'localhost:3000')).toBe(false)
})

it('allows connections without an Origin header (non-browser clients)', () => {
expect(isAllowedRpcOrigin(undefined, 'localhost:3000')).toBe(true)
expect(isAllowedRpcOrigin(null, 'localhost:3000')).toBe(true)
expect(isAllowedRpcOrigin('', 'localhost:3000')).toBe(true)
})

it('rejects a malformed Origin header', () => {
expect(isAllowedRpcOrigin('not a url', 'localhost:3000')).toBe(false)
})

it('rejects when the Host header is missing but an Origin is present', () => {
expect(isAllowedRpcOrigin('http://localhost:3000', undefined)).toBe(false)
})
})
Loading
Loading