Skip to content
Open
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
6 changes: 2 additions & 4 deletions packages/devtools-kit/src/_types/custom-tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,8 @@ export interface ModuleCustomTab {
extraTabVNode?: VNode

/**
* Require local authentication to access the tab
* It's highly recommended to enable this if the tab have sensitive information or have access to the OS
*
* @default false
* @deprecated No longer enforced client-side. Access to sensitive RPC
* methods is gated by Vite DevTools' own connection authorization instead.
*/
requireAuth?: boolean
}
Expand Down
9 changes: 8 additions & 1 deletion packages/devtools/client/composables/frame-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,22 @@ export function setupFrameNav(): void {
return manifest.value.find(entry => entry.navTarget.path === path)?.id
}

// The host page and this client are always served same-origin by the Nuxt
// dev server (this frame is embedded via an iframe on the app's own origin),
// so the host's origin is exactly our own — never '*'.
const hostOrigin = window.location.origin

function post(message: Record<string, unknown>) {
window.parent.postMessage({ channel: CHANNEL, v: VERSION, frameId: FRAME_ID, from: 'frame', ...message }, '*')
window.parent.postMessage({ channel: CHANNEL, v: VERSION, frameId: FRAME_ID, from: 'frame', ...message }, hostOrigin)
}

function announce(type: 'ready' | 'manifest') {
post({ type, tabs: manifest.value, current: currentTabId() })
}

window.addEventListener('message', (ev: MessageEvent) => {
if (ev.origin !== hostOrigin || ev.source !== window.parent)
return
const data = ev.data
if (!data || data.channel !== CHANNEL || data.v !== VERSION || data.frameId !== FRAME_ID || data.from !== 'host')
return
Expand Down
7 changes: 0 additions & 7 deletions packages/devtools/client/pages/modules/custom-[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ 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 { rpc } from '~/composables/rpc'
import { useAllTabs } from '~/composables/state-tabs'

Expand All @@ -29,9 +28,6 @@ onMounted(() => {
router.push('/modules/overview')
}, 2000)
}
else if (tab.value.requireAuth && !isDevAuthed.value) {
requestForAuth()
}
})
</script>

Expand All @@ -52,9 +48,6 @@ onMounted(() => {
</div>
</NPanelGrids>
</template>
<template v-else-if="tab.requireAuth && !isDevAuthed">
<AuthRequiredPanel />
</template>
<template v-else-if="tab.view.type === 'iframe'">
<IframeView :tab="tab" />
</template>
Expand Down
9 changes: 0 additions & 9 deletions packages/devtools/client/pages/modules/overview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ const isMacOS = getIsMacOS()

const vueVersion = computed(() => client.value?.nuxt.vueApp.version)
const metricsLoading = computed(() => client.value?.metrics.loading())

function authorize() {
// Auth is now handled by Vite DevTools
}
</script>

<template>
Expand Down Expand Up @@ -130,11 +126,6 @@ function authorize() {
<NTip v-if="showConnectionWarning" n="yellow5" icon="carbon-unlink" justify-center>
Not connected to the client app, showing server-side data only. Use the embedded mode for full features.
</NTip>
<button title="Authorize" @click="authorize">
<NTip v-if="!isDevAuthed" n="orange5" icon="i-carbon-locked" justify-center>
Access from an untrusted browser, some features are limited. Click to authorize now.
</NTip>
</button>
</div>
<div flex="~ gap-6 wrap" mt-5 items-center justify-center>
<a
Expand Down
48 changes: 44 additions & 4 deletions packages/devtools/src/server-rpc/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AssetEntry, AssetInfo, AssetType, ImageMeta, NuxtDevtoolsServerCon
import fsp from 'node:fs/promises'
import { parse, relative } from 'node:path'
import { imageMeta } from 'image-meta'
import { join, resolve } from 'pathe'
import { dirname, join, resolve } from 'pathe'
import { debounce } from 'perfect-debounce'
import { glob } from 'tinyglobby'
import { defaultAllowedExtensions } from '../constant'
Expand Down Expand Up @@ -100,12 +100,24 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
}
},
async writeStaticAssets(files: AssetEntry[], folder: string) {
const baseDir = resolve(nuxt.options.srcDir, nuxt.options.dir.public + folder)
// Strip any leading slashes so an absolute-looking `folder`/`path` is
// always treated as relative, then verify the resolved target is still
// contained — resolving against a string-concatenated, unchecked base
// (the previous approach) let `folder` escape the public directory
// before the containment check ever ran.
const baseDir = resolve(publicDir, folder.replace(/^[/\\]+/, ''))
if (baseDir !== publicDir && !baseDir.startsWith(`${publicDir}/`))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
throw new Error(`[Nuxt DevTools] Folder ${folder} is not allowed to upload, it's outside of the public directory`)

// Canonicalize once so the per-file realpath check below is comparing
// against the same resolved boundary (publicDir itself may sit behind
// a symlink, e.g. macOS's /tmp -> /private/tmp).
const realPublicDir = await realpathOfNearestAncestor(publicDir)

return await Promise.all(
files.map(async ({ path, content, encoding, override }) => {
let finalPath = resolve(baseDir, path)
if (!finalPath.startsWith(baseDir))
let finalPath = resolve(baseDir, path.replace(/^[/\\]+/, ''))
if (finalPath !== baseDir && !finalPath.startsWith(`${baseDir}/`))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
throw new Error(`[Nuxt DevTools] File ${path} is not allowed to upload, it's outside of the public directory`)

const { ext } = parse(finalPath)
Expand All @@ -114,6 +126,17 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
throw new Error(`[Nuxt DevTools] File extension ${ext} is not allowed to upload, allowed extensions are: ${extensions.join(', ')}\nYou can configure it in Nuxt config at \`devtools.assets.uploadExtensions\`.`)
}

// Lexical containment doesn't stop `fsp.writeFile` from following a
// symlink out of the public directory: canonicalize the nearest
// existing ancestor and reject if it (or an existing target itself)
// escapes.
const realParentDir = await realpathOfNearestAncestor(dirname(finalPath))
if (realParentDir !== realPublicDir && !realParentDir.startsWith(`${realPublicDir}/`))
throw new Error(`[Nuxt DevTools] File ${path} is not allowed to upload, it's outside of the public directory`)
const targetStat = await fsp.lstat(finalPath).catch(() => undefined)
if (targetStat?.isSymbolicLink())
throw new Error(`[Nuxt DevTools] File ${path} is not allowed to upload, it's a symbolic link`)

if (!override) {
try {
await fsp.stat(finalPath)
Expand Down Expand Up @@ -146,6 +169,23 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
} satisfies Partial<ServerFunctions>
}

/**
* Resolve the real (symlink-free) path of `dir`, or of its nearest existing
* ancestor if `dir` itself doesn't exist yet — so callers can still verify
* containment before creating a new file/folder there.
*/
async function realpathOfNearestAncestor(dir: string): Promise<string> {
try {
return await fsp.realpath(dir)
}
catch {
const parent = dirname(dir)
if (parent === dir)
return dir
return realpathOfNearestAncestor(parent)
}
}

const reImage = /\.(?:png|jpe?g|jxl|gif|svg|webp|avif|ico|bmp|tiff?)$/i
const reVideo = /\.(?:mp4|webm|ogv|mov|avi|flv|wmv|mpg|mpeg|mkv|3gp|3g2|ts|mts|m2ts|vob|ogm|ogx|rm|rmvb|asf|amv|divx|m4v|svi|viv|f4v|f4p|f4a|f4b)$/i
const reAudio = /\.(?:mp3|wav|ogg|flac|aac|wma|alac|ape|ac3|dts|tta|opus|amr|aiff|au|mid|midi|ra|rm|wv|weba|dss|spx|vox|tak|dsf|dff|dsd|cda)$/i
Expand Down
87 changes: 87 additions & 0 deletions packages/devtools/test/write-static-assets.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { Nuxt } from 'nuxt/schema'
import type { NuxtDevtoolsServerContext } from '../src/types'
import fsp from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { setupAssetsRPC } from '../src/server-rpc/assets'

function fakeContext(srcDir: string): NuxtDevtoolsServerContext {
return {
nuxt: {
options: {
srcDir,
dir: { public: 'public' },
app: { baseURL: '/' },
_layers: [],
},
hook: () => {},
} as unknown as Nuxt,
options: {},
refresh: () => {},
} as unknown as NuxtDevtoolsServerContext
}

describe('writeStaticAssets', () => {
let root: string

beforeEach(async () => {
root = await fsp.mkdtemp(join(tmpdir(), 'devtools-assets-'))
await fsp.mkdir(join(root, 'public'), { recursive: true })
})

afterEach(async () => {
await fsp.rm(root, { recursive: true, force: true })
})

it('writes files inside the public directory', async () => {
const { writeStaticAssets } = setupAssetsRPC(fakeContext(root))
const [written] = await writeStaticAssets!([{ path: 'a.txt', content: 'hi' }], '')
expect(written).toBe(join(root, 'public', 'a.txt'))
expect(await fsp.readFile(written!, 'utf-8')).toBe('hi')
})

it('rejects a folder that escapes the public directory', async () => {
const { writeStaticAssets } = setupAssetsRPC(fakeContext(root))
await expect(
writeStaticAssets!([{ path: 'nuxt.config.ts', content: 'evil' }], '/../..'),
).rejects.toThrow(/outside of the public directory/)
})

it('rejects a file path that escapes the public directory', async () => {
const { writeStaticAssets } = setupAssetsRPC(fakeContext(root))
await expect(
writeStaticAssets!([{ path: '../../etc/passwd', content: 'evil' }], ''),
).rejects.toThrow(/outside of the public directory/)
})

it('treats an absolute-looking path as relative to the public directory', async () => {
const { writeStaticAssets } = setupAssetsRPC(fakeContext(root))
const [written] = await writeStaticAssets!([{ path: '/passwd.txt', content: 'not the real one' }], '')
expect(written).toBe(join(root, 'public', 'passwd.txt'))
})

it('rejects writing through a symlinked directory that points outside the public directory', async () => {
const outside = join(root, 'outside')
await fsp.mkdir(outside, { recursive: true })
await fsp.symlink(outside, join(root, 'public', 'linked'), 'dir')

const { writeStaticAssets } = setupAssetsRPC(fakeContext(root))
await expect(
writeStaticAssets!([{ path: 'evil.txt', content: 'evil' }], '/linked'),
).rejects.toThrow(/outside of the public directory/)
await expect(fsp.access(join(outside, 'evil.txt'))).rejects.toThrow()
})

it('rejects overwriting an existing symlink target', async () => {
const outsideFile = join(root, 'secret.txt')
await fsp.writeFile(outsideFile, 'original')
await fsp.symlink(outsideFile, join(root, 'public', 'link.txt'))

const { writeStaticAssets } = setupAssetsRPC(fakeContext(root))
await expect(
writeStaticAssets!([{ path: 'link.txt', content: 'evil', override: true }], ''),
).rejects.toThrow(/symbolic link/)
expect(await fsp.readFile(outsideFile, 'utf-8')).toBe('original')
})
})
13 changes: 6 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ minimumReleaseAgeExclude:
- db0@0.4.1
- h3@2.0.1-rc.31
- srvx@1.0.3
- skills-npm@1.2.1

resolutionMode: highest

Expand Down Expand Up @@ -126,7 +127,7 @@ catalogs:
nitropack: ^2.13.4
nuxt: *nuxt
shiki-codegen: *shiki
skills-npm: ^1.2.0
skills-npm: ^1.2.1
turbo: ^2.10.12
unbuild: ^3.6.1
unocss: *unocss
Expand Down
Loading