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
53 changes: 37 additions & 16 deletions packages/devtools/src/server-rpc/assets.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { AssetEntry, AssetInfo, AssetType, ImageMeta, NuxtDevtoolsServerContext, ServerFunctions } from '../types'
import { existsSync } from 'node:fs'
import fsp from 'node:fs/promises'
import { parse, relative } from 'node:path'
import { imageMeta } from 'image-meta'
import { dirname, join, resolve } from 'pathe'
import { dirname, extname, join, resolve } from 'pathe'
import { debounce } from 'perfect-debounce'
import { glob } from 'tinyglobby'
import { defaultAllowedExtensions } from '../constant'

const MAX_TEXT_PREVIEW = 10_000

export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerContext) {
const _imageMetaCache = new Map<string, ImageMeta | undefined>()
let cache: AssetInfo[] | null = null
Expand All @@ -15,6 +18,19 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
const publicDir = resolve(nuxt.options.srcDir, nuxt.options.dir.public)
const layerDirs = [publicDir, ...nuxt.options._layers.map(layer => resolve(layer.cwd, 'public'))]

// Every path-taking RPC below is called from the browser; only files under
// a scanned public directory are fair game. Compare canonical paths so a
// symlink inside `public/` cannot redirect the operation, and skip roots that
// don't exist yet rather than letting them canonicalise to their parent.
async function assertInsideAssets(path: string, action: string): Promise<string> {
const resolved = resolve(path)
const real = await realpathOfNearestAncestor(resolved)
const realLayerDirs = await Promise.all(layerDirs.filter(dir => existsSync(dir)).map(dir => fsp.realpath(dir)))
if (!realLayerDirs.some(dir => real === dir || real.startsWith(`${dir}/`)))
throw new Error(`[Nuxt DevTools] File ${path} is not allowed to ${action}, it's outside of the public directory`)
return resolved
}

const refreshDebounced = debounce(() => {
cache = null
refresh('getStaticAssets')
Expand Down Expand Up @@ -76,6 +92,7 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
return await scan()
},
async getImageMeta(filepath: string) {
filepath = await assertInsideAssets(filepath, 'read')
if (_imageMetaCache.has(filepath))
return _imageMetaCache.get(filepath)
try {
Expand All @@ -90,9 +107,10 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
}
},
async getTextAssetContent(filepath: string, limit = 300) {
filepath = await assertInsideAssets(filepath, 'read')
try {
const content = await fsp.readFile(filepath, 'utf-8')
return content.slice(0, limit)
return content.slice(0, Math.min(limit, MAX_TEXT_PREVIEW))
}
catch (e) {
console.error(e)
Expand Down Expand Up @@ -137,19 +155,8 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
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)
const base = finalPath.slice(0, finalPath.length - ext.length - 1)
let i = 1
while (await fsp.access(`${base}-${i}.${ext}`).then(() => true).catch(() => false))
i++
finalPath = `${base}-${i}.${ext}`
}
catch {
// Ignore error if file doesn't exist
}
}
if (!override)
finalPath = await collisionFreePath(finalPath)
await fsp.writeFile(finalPath, content, {
encoding: encoding ?? 'utf-8',
})
Expand All @@ -158,9 +165,11 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
)
},
async deleteStaticAsset(path: string) {
return await fsp.unlink(path)
return await fsp.unlink(await assertInsideAssets(path, 'delete'))
},
async renameStaticAsset(oldPath: string, newPath: string) {
oldPath = await assertInsideAssets(oldPath, 'rename')
newPath = await assertInsideAssets(newPath, 'rename to')
const exist = cache?.find(asset => asset.filePath === newPath)
if (exist)
throw new Error(`[Nuxt DevTools] File ${newPath} already exists, failed to rename`)
Expand All @@ -169,6 +178,18 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon
} satisfies Partial<ServerFunctions>
}

/** `logo.png` → `logo-1.png` (then `-2`, …) while the target exists. */
export async function collisionFreePath(path: string, exists = (p: string) => fsp.access(p).then(() => true, () => false)): Promise<string> {
if (!await exists(path))
return path
const ext = extname(path)
const stem = path.slice(0, path.length - ext.length)
let i = 1
while (await exists(`${stem}-${i}${ext}`))
i++
return `${stem}-${i}${ext}`
}

/**
* 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
Expand Down
13 changes: 8 additions & 5 deletions packages/devtools/src/server-rpc/storage.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import type { Storage, StorageValue } from 'unstorage'
import type { NuxtDevtoolsServerContext, ServerFunctions } from '../types'
import type { AnyNitro, AnyStorageMounts } from '../utils/nitro-compat'
import { builtinDrivers, createStorage } from 'unstorage'
import { builtinDrivers, createStorage, normalizeKey } from 'unstorage'
import { watchStorageMount } from './storage-watch'

// Mounts backed by the project itself (`root`/`src` are the filesystem) stay
// off limits for listing and for every item operation alike. Normalise first:
// unstorage routes `/root:x`, `:root:x` and `root/x` to the `root` mount too.
const IGNORE_STORAGE_MOUNTS = ['root', 'build', 'src', 'cache']
function shouldIgnoreStorageKey(key: string) {
return IGNORE_STORAGE_MOUNTS.includes(key.split(':')[0]!)
return IGNORE_STORAGE_MOUNTS.includes(normalizeKey(key).split(':')[0]!)
}

export function setupStorageRPC(ctx: NuxtDevtoolsServerContext) {
Expand Down Expand Up @@ -95,17 +98,17 @@ export function setupStorageRPC(ctx: NuxtDevtoolsServerContext) {
}
},
async getStorageItem(key: string) {
if (!storage)
if (!storage || shouldIgnoreStorageKey(key))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return null
return await storage.getItem(key)
},
async setStorageItem(key: string, value: StorageValue) {
if (!storage)
if (!storage || shouldIgnoreStorageKey(key))
return
return await storage.setItem(key, value)
},
async removeStorageItem(key: string) {
if (!storage)
if (!storage || shouldIgnoreStorageKey(key))
return
return await storage.removeItem(key)
},
Expand Down
74 changes: 74 additions & 0 deletions packages/devtools/test/assets-rpc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { NuxtDevtoolsServerContext } from '../src/types'
import fsp from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createHooks } from 'hookable'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { collisionFreePath, setupAssetsRPC } from '../src/server-rpc/assets'

let root: string
let publicDir: string
let rpc: ReturnType<typeof setupAssetsRPC>

beforeEach(async () => {
root = await fsp.mkdtemp(join(tmpdir(), 'nuxt-devtools-assets-'))
publicDir = join(root, 'public')
await fsp.mkdir(publicDir)
await fsp.writeFile(join(publicDir, 'notes.txt'), 'x'.repeat(20_000))
await fsp.writeFile(join(root, 'secret.txt'), 'top secret')

const hooks = createHooks()
rpc = setupAssetsRPC({
nuxt: {
hook: hooks.hook.bind(hooks),
options: { srcDir: root, dir: { public: 'public' }, _layers: [], app: { baseURL: '/' } },
},
refresh: () => {},
options: {},
} as unknown as NuxtDevtoolsServerContext)
})

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

describe('assets RPC path containment', () => {
it('refuses to read, delete or rename anything outside the public directories', async () => {
const secret = join(root, 'secret.txt')
const sibling = join(root, 'publicX', 'a.txt')

await expect(rpc.getTextAssetContent(secret)).rejects.toThrow(/outside of the public directory/)
await expect(rpc.getImageMeta(secret)).rejects.toThrow(/outside of the public directory/)
await expect(rpc.getTextAssetContent(join(publicDir, '..', 'secret.txt'))).rejects.toThrow()
await expect(rpc.getTextAssetContent(sibling)).rejects.toThrow()
await expect(rpc.deleteStaticAsset(secret)).rejects.toThrow(/outside of the public directory/)
await expect(rpc.renameStaticAsset(join(publicDir, 'notes.txt'), secret)).rejects.toThrow(/outside of the public directory/)

expect(await fsp.readFile(secret, 'utf-8')).toBe('top secret')
})

it('does not follow symlinks out of the public directory', async () => {
await fsp.symlink(join(root, 'secret.txt'), join(publicDir, 'linked.txt'))
await fsp.symlink(root, join(publicDir, 'linked-dir'))

await expect(rpc.getTextAssetContent(join(publicDir, 'linked.txt'))).rejects.toThrow(/outside of the public directory/)
await expect(rpc.getTextAssetContent(join(publicDir, 'linked-dir', 'secret.txt'))).rejects.toThrow(/outside of the public directory/)
await expect(rpc.deleteStaticAsset(join(publicDir, 'linked-dir', 'secret.txt'))).rejects.toThrow(/outside of the public directory/)

expect(await fsp.readFile(join(root, 'secret.txt'), 'utf-8')).toBe('top secret')
})

it('serves files inside public and caps the text preview', async () => {
const content = await rpc.getTextAssetContent(join(publicDir, 'notes.txt'), 1_000_000)
expect(content).toHaveLength(10_000)
})
})

describe('collisionFreePath', () => {
const existing = (present: string[]) => async (p: string) => present.includes(p)

it('keeps the extension intact when suffixing', async () => {
expect(await collisionFreePath('/p/logo.png', existing(['/p/logo.png']))).toBe('/p/logo-1.png')
expect(await collisionFreePath('/p/logo.png', existing(['/p/logo.png', '/p/logo-1.png']))).toBe('/p/logo-2.png')
expect(await collisionFreePath('/p/LICENSE', existing(['/p/LICENSE']))).toBe('/p/LICENSE-1')
expect(await collisionFreePath('/p/logo.png', existing([]))).toBe('/p/logo.png')
})
})
62 changes: 62 additions & 0 deletions packages/devtools/test/storage-denylist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { NuxtDevtoolsServerContext } from '../src/types'
import fsp from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createHooks } from 'hookable'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { setupStorageRPC } from '../src/server-rpc/storage'

let root: string
let projectDir: string
let rpc: ReturnType<typeof setupStorageRPC>

// `root` is an fs mount over a stand-in project dir, like Nitro's dev `root`
// mount; `db` is an ordinary user mount.
beforeEach(async () => {
root = await fsp.mkdtemp(join(tmpdir(), 'nuxt-devtools-storage-'))
projectDir = join(root, 'project')
await fsp.mkdir(projectDir)
await fsp.writeFile(join(projectDir, 'nuxt.config.ts'), 'original')

const hooks = createHooks()
rpc = setupStorageRPC({ nuxt: { hook: hooks.hook.bind(hooks) } } as unknown as NuxtDevtoolsServerContext)
await hooks.callHook('nitro:init', {
options: {
storage: {
root: { driver: 'fs', base: projectDir },
db: { driver: 'fs', base: join(root, 'db') },
},
},
logger: { warn: () => {} },
})
})

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

const readProjectConfig = () => fsp.readFile(join(projectDir, 'nuxt.config.ts'), 'utf-8')

describe('storage mount denylist', () => {
// Every spelling unstorage routes to the `root` mount.
const deniedKeys = ['root:nuxt.config.ts', '/root:nuxt.config.ts', ':root:nuxt.config.ts', 'root/nuxt.config.ts', '\\root\\nuxt.config.ts']

it.each(deniedKeys)('refuses to read, write or remove %s', async (key) => {
expect(await rpc.getStorageItem(key)).toBeNull()

await rpc.setStorageItem(key, 'overwritten')
expect(await readProjectConfig()).toBe('original')

await rpc.removeStorageItem(key)
expect(await readProjectConfig()).toBe('original')
})

it('lists and serves user mounts only', async () => {
await rpc.setStorageItem('db:users', 'alice')

expect(await rpc.getStorageItem('db:users')).toBe('alice')
expect(await rpc.getStorageKeys()).toEqual(['db:users'])
expect(Object.keys(await rpc.getStorageMounts())).toEqual(['db'])

await rpc.removeStorageItem('db:users')
expect(await rpc.getStorageKeys()).toEqual([])
})
})
Loading
Loading