diff --git a/packages/devtools/src/server-rpc/assets.ts b/packages/devtools/src/server-rpc/assets.ts index abbc99a385..30c9b49ccc 100644 --- a/packages/devtools/src/server-rpc/assets.ts +++ b/packages/devtools/src/server-rpc/assets.ts @@ -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() let cache: AssetInfo[] | null = null @@ -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 { + 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') @@ -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 { @@ -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) @@ -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', }) @@ -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`) @@ -169,6 +178,18 @@ export function setupAssetsRPC({ nuxt, refresh, options }: NuxtDevtoolsServerCon } satisfies Partial } +/** `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 { + 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 diff --git a/packages/devtools/src/server-rpc/storage.ts b/packages/devtools/src/server-rpc/storage.ts index 76c0ef699a..471849685b 100644 --- a/packages/devtools/src/server-rpc/storage.ts +++ b/packages/devtools/src/server-rpc/storage.ts @@ -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) { @@ -95,17 +98,17 @@ export function setupStorageRPC(ctx: NuxtDevtoolsServerContext) { } }, async getStorageItem(key: string) { - if (!storage) + if (!storage || shouldIgnoreStorageKey(key)) 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) }, diff --git a/packages/devtools/test/assets-rpc.test.ts b/packages/devtools/test/assets-rpc.test.ts new file mode 100644 index 0000000000..02d39949e1 --- /dev/null +++ b/packages/devtools/test/assets-rpc.test.ts @@ -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 + +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') + }) +}) diff --git a/packages/devtools/test/storage-denylist.test.ts b/packages/devtools/test/storage-denylist.test.ts new file mode 100644 index 0000000000..9bd244e42f --- /dev/null +++ b/packages/devtools/test/storage-denylist.test.ts @@ -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 + +// `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([]) + }) +}) diff --git a/plans/005-harden-assets-rpc.md b/plans/005-harden-assets-rpc.md deleted file mode 100644 index 2394b4039f..0000000000 --- a/plans/005-harden-assets-rpc.md +++ /dev/null @@ -1,307 +0,0 @@ -# Plan 005: Harden the assets RPC — path containment + fix the collision-rename bug - -> **Executor instructions**: Follow this plan step by step. Run every -> verification command and confirm the expected result before moving on. If a -> STOP condition occurs, stop and report — do not improvise. When done, update -> the status row for this plan in `plans/README.md` — unless a reviewer -> dispatched you and told you they maintain the index. -> -> **Drift check (run first)**: `git diff --stat c75c3b9..HEAD -- packages/devtools/src/server-rpc/assets.ts` -> If that file changed since this plan was written, compare the "Current state" -> excerpts against the live code; on a mismatch, STOP. - -## Status - -- **Priority**: P1 -- **Effort**: S–M -- **Risk**: LOW -- **Depends on**: plans/001-unit-test-baseline.md -- **Category**: security / bug -- **Planned at**: commit `c75c3b9`, 2026-07-14 - -## Why this matters - -The assets RPC functions run on the dev server and are called from the browser -DevTools client. Three defensive-maintenance problems, framed as code changes: - -1. **Containment bypass in `writeStaticAssets`** — the "sandbox" base dir is built - by string-concatenating a client-supplied `folder` - (`resolve(srcDir, dir.public + folder)`), and the guard is a separator-less - `finalPath.startsWith(baseDir)` (a sibling like `.../publicX` satisfies it). The - default extension allowlist also permits `.js/.ts/.vue/.json`, so a write can land - executable source that Vite may load. The function *intends* to confine writes to - `/public`; the confinement is incomplete. -2. **No containment at all on read/delete/rename** — `getTextAssetContent`, - `getImageMeta`, `deleteStaticAsset`, and `renameStaticAsset` take arbitrary paths - with no boundary check (asymmetric with the write path). `getTextAssetContent`'s - `limit` is client-controlled, so the 300-byte preview cap isn't a real bound. -3. **Collision-rename produces a corrupt filename** — when a same-named asset - exists and `override` is false, the "add `-N` suffix" branch does - `base = finalPath.slice(0, len - ext.length - 1)` where `ext` (from - `path.parse`) already includes the leading dot, then builds `${base}-${i}.${ext}`. - For `logo.png` this yields `log-1..png` (drops a char and doubles the dot). - -## Current state - -- `packages/devtools/src/server-rpc/assets.ts`. Relevant excerpts: - ```ts - // assets.ts:78-101 — read paths, no containment; client-controlled limit - async getImageMeta(filepath: string) { ... imageMeta(await fsp.readFile(filepath)) ... } - async getTextAssetContent(filepath: string, limit = 300) { - const content = await fsp.readFile(filepath, 'utf-8') - return content.slice(0, limit) - } - - // assets.ts:102-136 — write path; bypassable containment + collision bug - async writeStaticAssets(files: AssetEntry[], folder: string) { - const baseDir = resolve(nuxt.options.srcDir, nuxt.options.dir.public + folder) - return await Promise.all(files.map(async ({ path, content, encoding, override }) => { - let finalPath = resolve(baseDir, path) - if (!finalPath.startsWith(baseDir)) - throw new Error(`[Nuxt DevTools] File ${path} is not allowed to upload, it's outside of the public directory`) - const { ext } = parse(finalPath) // ext includes the dot, e.g. ".png" - if (extensions !== '*') { - if (!extensions.includes(ext.toLowerCase().slice(1))) - throw new Error(...) - } - if (!override) { - try { - await fsp.stat(finalPath) - const base = finalPath.slice(0, finalPath.length - ext.length - 1) // BUG: -1 too many - let i = 1 - while (await fsp.access(`${base}-${i}.${ext}`).then(() => true).catch(() => false)) - i++ - finalPath = `${base}-${i}.${ext}` // BUG: `.${ext}` double dot - } - catch { /* Ignore error if file doesn't exist */ } - } - await fsp.writeFile(finalPath, content, { encoding: encoding ?? 'utf-8' }) - return finalPath - })) - } - - // assets.ts:137-145 — delete/rename, no containment - async deleteStaticAsset(path: string) { return await fsp.unlink(path) } - async renameStaticAsset(oldPath: string, newPath: string) { ... return await fsp.rename(oldPath, newPath) } - ``` -- Imports at the top: `import { parse, relative } from 'node:path'`, - `import { join, resolve } from 'pathe'`, `import fsp from 'node:fs/promises'`. -- The scanned asset roots are already computed as `layerDirs`: - ```ts - // assets.ts:15-16 - const publicDir = resolve(nuxt.options.srcDir, nuxt.options.dir.public) - const layerDirs = [publicDir, ...nuxt.options._layers.map(layer => resolve(layer.cwd, 'public'))] - ``` -- Default allowlist (`packages/devtools/src/constant.ts:66-95`, `defaultAllowedExtensions`) - includes `json`, `js`, `jsx`, `ts`, `tsx`, `md`, `mdx`, `vue`. -- `pathe` exports `resolve`, `relative`, `sep` (POSIX-normalized). Prefer `pathe` - for the boundary math for cross-platform consistency with the rest of the file. - -## Commands you will need - -| Purpose | Command | Expected | -|---------------|------------------------------------------|--------------------| -| Install | `pnpm install` | exit 0 | -| This test | `pnpm test:unit -- asset-paths` | new tests pass | -| All unit tests| `pnpm test:unit` | exit 0 | -| Lint | `pnpm lint` (autofix: `pnpm lint --fix`) | exit 0 | - -## Scope - -**In scope**: -- `packages/devtools/src/utils/asset-paths.ts` (create — pure, testable helpers) -- `packages/devtools/src/server-rpc/assets.ts` (use the helpers; fix the rename bug) -- `packages/devtools/test/asset-paths.test.ts` (create) - -**Out of scope** (do NOT touch): -- `packages/devtools/src/constant.ts` `defaultAllowedExtensions` — do NOT change the - allowlist in this plan (removing executable extensions is a behavior change better - raised with the maintainer; note it in Maintenance). The containment fix is the - primary mitigation. -- The `scan()` / `getStaticAssets()` listing logic and its cache. -- The `builder:watch` refresh wiring. - -## Git workflow - -- Branch: `fix/005-harden-assets-rpc` off the base branch. -- Conventional Commits. Suggested: `fix(devtools): contain asset RPC paths and fix collision filename`. -- Do NOT push or open a PR unless instructed. - -## Steps - -### Step 1: Create pure path helpers - -Create `packages/devtools/src/utils/asset-paths.ts`: - -```ts -import { relative, resolve } from 'pathe' - -/** True if `target` resolves to `root` itself or something inside it. */ -export function isInsideDir(root: string, target: string): boolean { - const rel = relative(resolve(root), resolve(target)) - return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/') && !/^[a-z]:/i.test(rel)) -} - -/** True if `target` is inside any of `roots`. */ -export function isInsideAnyDir(roots: string[], target: string): boolean { - return roots.some(root => isInsideDir(root, target)) -} - -/** - * Resolve `path` inside `baseDir`, returning a collision-free variant when a file - * already exists (uses `exists` to probe). Correctly preserves the extension: - * `logo.png` -> `logo-1.png` (NOT `log-1..png`). - */ -export async function resolveCollisionFreePath( - baseDir: string, - path: string, - override: boolean | undefined, - exists: (p: string) => Promise, -): Promise { - let finalPath = resolve(baseDir, path) - if (override || !(await exists(finalPath))) - return finalPath - const dot = finalPath.lastIndexOf('.') - const stem = dot > finalPath.lastIndexOf('/') ? finalPath.slice(0, dot) : finalPath - const ext = dot > finalPath.lastIndexOf('/') ? finalPath.slice(dot) : '' // includes the dot - let i = 1 - while (await exists(`${stem}-${i}${ext}`)) - i++ - finalPath = `${stem}-${i}${ext}` - return finalPath -} -``` - -**Verify**: `test -f packages/devtools/src/utils/asset-paths.ts && echo OK` → `OK`. - -### Step 2: Unit-test the helpers (including the exact bug regressions) - -Create `packages/devtools/test/asset-paths.test.ts` (pattern from plan 001): - -```ts -import { describe, expect, it } from 'vitest' -import { isInsideAnyDir, isInsideDir, resolveCollisionFreePath } from '../src/utils/asset-paths' - -describe('isInsideDir', () => { - it('accepts paths inside the root', () => { - expect(isInsideDir('/app/public', '/app/public/img/logo.png')).toBe(true) - expect(isInsideDir('/app/public', '/app/public')).toBe(true) - }) - it('rejects traversal and sibling-prefix escapes', () => { - expect(isInsideDir('/app/public', '/app/public/../secret')).toBe(false) - expect(isInsideDir('/app/public', '/app/publicX/evil')).toBe(false) // separator-less prefix escape - expect(isInsideDir('/app/public', '/etc/passwd')).toBe(false) - }) -}) - -describe('isInsideAnyDir', () => { - it('accepts a path in any listed root', () => { - expect(isInsideAnyDir(['/app/public', '/layer/public'], '/layer/public/a.png')).toBe(true) - expect(isInsideAnyDir(['/app/public'], '/other/a.png')).toBe(false) - }) -}) - -describe('resolveCollisionFreePath', () => { - const exists = (present: string[]) => async (p: string) => present.includes(p) - it('returns the plain path when nothing exists', async () => { - expect(await resolveCollisionFreePath('/p', 'logo.png', false, exists([]))).toBe('/p/logo.png') - }) - it('suffixes correctly on collision (regression: not log-1..png)', async () => { - const out = await resolveCollisionFreePath('/p', 'logo.png', false, exists(['/p/logo.png'])) - expect(out).toBe('/p/logo-1.png') - }) - it('handles extensionless files', async () => { - const out = await resolveCollisionFreePath('/p', 'LICENSE', false, exists(['/p/LICENSE'])) - expect(out).toBe('/p/LICENSE-1') - }) - it('returns the plain path when override is true', async () => { - expect(await resolveCollisionFreePath('/p', 'logo.png', true, exists(['/p/logo.png']))).toBe('/p/logo.png') - }) -}) -``` - -**Verify**: `pnpm test:unit -- asset-paths` → all pass. - -### Step 3: Apply the helpers in `assets.ts` - -- Add `import { isInsideAnyDir, resolveCollisionFreePath } from '../utils/asset-paths'`. -- **Read functions** — at the top of `getImageMeta(filepath)` and - `getTextAssetContent(filepath, limit = 300)`, reject out-of-tree paths: - ```ts - if (!isInsideAnyDir(layerDirs, filepath)) - throw new Error(`[Nuxt DevTools] File ${filepath} is outside the asset directories`) - ``` - Also clamp the preview: `const max = Math.min(Math.max(0, limit), 10_000)` and - `return content.slice(0, max)` so the client can't request an unbounded read. -- **`deleteStaticAsset(path)`** and both args of **`renameStaticAsset(oldPath, newPath)`** - — guard with `isInsideAnyDir(layerDirs, ...)` before `fsp.unlink` / `fsp.rename`, - throwing the same style of error when outside. -- **`writeStaticAssets`** — replace the manual base-dir concat + `startsWith` check + - collision loop: - ```ts - const baseDir = resolve(publicDir, folder) // resolve normalizes ".." in folder - return await Promise.all(files.map(async ({ path, content, encoding, override }) => { - const finalPath = await resolveCollisionFreePath( - baseDir, path, override, - p => fsp.access(p).then(() => true).catch(() => false), - ) - if (!isInsideAnyDir(layerDirs, finalPath)) - throw new Error(`[Nuxt DevTools] File ${path} is not allowed to upload, it's outside of the public directory`) - const { ext } = parse(finalPath) - if (extensions !== '*' && !extensions.includes(ext.toLowerCase().slice(1))) - throw new Error(`[Nuxt DevTools] File extension ${ext} is not allowed to upload, allowed extensions are: ${(extensions as string[]).join(', ')}\nYou can configure it in Nuxt config at \`devtools.assets.uploadExtensions\`.`) - await fsp.writeFile(finalPath, content, { encoding: encoding ?? 'utf-8' }) - return finalPath - })) - ``` - Note: do the extension check on the resolved `finalPath`, and containment on the - resolved path so `folder` can no longer relocate the base outside `public`. - -**Verify**: -- `pnpm test:unit` → exit 0. -- `grep -n "finalPath.length - ext.length - 1" packages/devtools/src/server-rpc/assets.ts` → no matches. -- `grep -n "startsWith(baseDir)" packages/devtools/src/server-rpc/assets.ts` → no matches. -- `pnpm lint` → exit 0 (`pnpm lint --fix` if needed). - -## Test plan - -- New file `packages/devtools/test/asset-paths.test.ts` covering: inside/at-root - acceptance, `..` traversal + separator-less sibling-prefix rejection, multi-root - membership, and the collision-rename regression (`logo-1.png`, not `log-1..png`), - extensionless files, and `override`. -- Structural pattern: `packages/devtools/test/serialize-js-literal.test.ts` (plan 001). -- Verification: `pnpm test:unit` → all pass. - -## Done criteria - -- [ ] `packages/devtools/src/utils/asset-paths.ts` exists and is used by `assets.ts`. -- [ ] `pnpm test:unit` exits 0; `asset-paths.test.ts` passes (incl. the `logo-1.png` regression). -- [ ] `grep -rn "finalPath.length - ext.length - 1" packages/devtools/src` → no matches. -- [ ] `grep -rn "startsWith(baseDir)" packages/devtools/src` → no matches. -- [ ] `getImageMeta`, `getTextAssetContent`, `deleteStaticAsset`, `renameStaticAsset`, `writeStaticAssets` all reject out-of-tree paths. -- [ ] `pnpm lint` exits 0. -- [ ] Only the three in-scope files are modified/created (`git status`). -- [ ] `plans/README.md` status row for 005 updated. - -## STOP conditions - -Stop and report (do not improvise) if: - -- `assets.ts` doesn't match the "Current state" excerpts (drift). -- Legitimate DevTools asset operations demonstrably pass paths *outside* `layerDirs` - (e.g. the UI is expected to preview files from `node_modules` or `.output`) — if - so, the containment set may need widening; report the real call sites rather than - loosening the guard to allow everything. -- A verification fails twice after a reasonable fix attempt. - -## Maintenance notes - -- Deferred (maintainer decision): trimming executable extensions - (`js/ts/tsx/vue/json/md/mdx`) from `defaultAllowedExtensions` in `constant.ts`. - Containment is the primary fix; the allowlist trim is a separate behavior change. -- The broader context (audit SEC-04): these RPCs sit behind transport-level auth - delegated to Vite DevTools; confirm that layer authenticates / origin-checks - callers. This plan hardens the functions' own input validation regardless. -- Reviewer: verify `layerDirs` is the correct containment set (public dir + each - layer's public dir) and that `resolve(publicDir, folder)` plus the post-resolve - containment check closes the `folder`-relocation hole. diff --git a/plans/006-fix-storage-denylist.md b/plans/006-fix-storage-denylist.md deleted file mode 100644 index c3deba0833..0000000000 --- a/plans/006-fix-storage-denylist.md +++ /dev/null @@ -1,204 +0,0 @@ -# Plan 006: Enforce the storage mount denylist on item access, not just listing - -> **Executor instructions**: Follow this plan step by step. Run every -> verification command and confirm the expected result before moving on. If a -> STOP condition occurs, stop and report — do not improvise. When done, update -> the status row for this plan in `plans/README.md` — unless a reviewer -> dispatched you and told you they maintain the index. -> -> **Drift check (run first)**: `git diff --stat c75c3b9..HEAD -- packages/devtools/src/server-rpc/storage.ts` -> If that file changed since this plan was written, compare the "Current state" -> excerpt against the live code; on a mismatch, STOP. - -## Status - -- **Priority**: P1 -- **Effort**: S -- **Risk**: LOW -- **Depends on**: plans/001-unit-test-baseline.md -- **Category**: security -- **Planned at**: commit `c75c3b9`, 2026-07-14 - -## Why this matters - -The storage RPC deliberately hides the `root`, `build`, `src`, and `cache` -unstorage mounts from the client — but only in the **listing** function -(`getStorageKeys` filters them). `getStorageItem`, `setStorageItem`, and -`removeStorageItem` apply **no** such filter, so a caller that names a key under -those mounts (e.g. `root:...`, `src:...`) can read, overwrite, or delete it. In -Nitro dev, `root`/`src` mounts are backed by the project filesystem, so this is a -filesystem read/write/delete primitive that the UI intends to keep off-limits. The -denylist is currently security-by-obscurity: hidden from view, but not access-gated. -This plan applies the existing guard to the item operations. - -## Current state - -- `packages/devtools/src/server-rpc/storage.ts`, verbatim: - ```ts - // storage.ts:6-9 - const IGNORE_STORAGE_MOUNTS = ['root', 'build', 'src', 'cache'] - function shouldIgnoreStorageKey(key: string) { - return IGNORE_STORAGE_MOUNTS.includes(key.split(':')[0]!) - } - - // storage.ts:60-87 — listing filters; item ops do NOT - async getStorageKeys(base?: string) { - if (!storage) return [] - try { - const keys = await storage.getKeys(base) - return keys.filter(key => !shouldIgnoreStorageKey(key)) // <-- guard here only - } - catch (err) { - console.error(`Cloud not fetch storage keys for ${base}:`, err) - return [] - } - } - async getStorageItem(key: string) { - if (!storage) return null - return await storage.getItem(key) // <-- no guard - } - async setStorageItem(key: string, value: StorageValue) { - if (!storage) return - return await storage.setItem(key, value) // <-- no guard - } - async removeStorageItem(key: string) { - if (!storage) return - return await storage.removeItem(key) // <-- no guard - } - ``` - (Note: the `console.error` string has a typo "Cloud not fetch" — fixing it is - optional here; the storage denylist is the point.) - -## Commands you will need - -| Purpose | Command | Expected | -|---------------|------------------------------------------|--------------------| -| Install | `pnpm install` | exit 0 | -| This test | `pnpm test:unit -- storage` | new tests pass | -| All unit tests| `pnpm test:unit` | exit 0 | -| Lint | `pnpm lint` (autofix: `pnpm lint --fix`) | exit 0 | - -## Scope - -**In scope**: -- `packages/devtools/src/server-rpc/storage.ts` (export the predicate; gate item ops) -- `packages/devtools/test/storage-denylist.test.ts` (create) - -**Out of scope** (do NOT touch): -- The mount discovery / `nitro:init` / watch wiring in the same file. -- `IGNORE_STORAGE_MOUNTS`'s membership — keep the same four mounts. -- Any other RPC module. - -## Git workflow - -- Branch: `fix/006-storage-denylist` off the base branch. -- Conventional Commits. Suggested: `fix(devtools): enforce storage mount denylist on item access`. -- Do NOT push or open a PR unless instructed. - -## Steps - -### Step 1: Export the predicate so it is unit-testable - -In `storage.ts`, make the existing helper exported (so a test can import it without -a storage instance): - -```ts -export function shouldIgnoreStorageKey(key: string) { - return IGNORE_STORAGE_MOUNTS.includes(key.split(':')[0]!) -} -``` - -**Verify**: `grep -n "export function shouldIgnoreStorageKey" packages/devtools/src/server-rpc/storage.ts` → 1 match. - -### Step 2: Gate the three item operations - -At the top of each of `getStorageItem`, `setStorageItem`, and `removeStorageItem`, -reject denied keys. Read returns `null`; write/remove no-op or throw consistently -with the existing early returns: - -```ts -async getStorageItem(key: string) { - if (!storage || shouldIgnoreStorageKey(key)) - return null - return await storage.getItem(key) -}, -async setStorageItem(key: string, value: StorageValue) { - if (!storage || shouldIgnoreStorageKey(key)) - return - return await storage.setItem(key, value) -}, -async removeStorageItem(key: string) { - if (!storage || shouldIgnoreStorageKey(key)) - return - return await storage.removeItem(key) -}, -``` - -**Verify**: `grep -c "shouldIgnoreStorageKey" packages/devtools/src/server-rpc/storage.ts` -→ at least 5 (definition + list filter + 3 item ops). - -### Step 3: Unit-test the predicate - -Create `packages/devtools/test/storage-denylist.test.ts` (pattern from plan 001): - -```ts -import { describe, expect, it } from 'vitest' -import { shouldIgnoreStorageKey } from '../src/server-rpc/storage' - -describe('shouldIgnoreStorageKey', () => { - it('ignores denied mounts (root/build/src/cache) at any depth', () => { - expect(shouldIgnoreStorageKey('root:nuxt.config.ts')).toBe(true) - expect(shouldIgnoreStorageKey('src:pages/index.vue')).toBe(true) - expect(shouldIgnoreStorageKey('build:x')).toBe(true) - expect(shouldIgnoreStorageKey('cache:x')).toBe(true) - }) - it('allows user mounts', () => { - expect(shouldIgnoreStorageKey('db:users:1')).toBe(false) - expect(shouldIgnoreStorageKey('redis:session')).toBe(false) - }) -}) -``` - -> Importing `storage.ts` must not have import-time side effects beyond defining -> functions. If importing it pulls in `@nuxt/kit`/nitro types that break the test -> environment, move only the two pure symbols (`IGNORE_STORAGE_MOUNTS` + -> `shouldIgnoreStorageKey`) into a sibling `storage-keys.ts` and re-export from -> `storage.ts` — then import the helper from there in both the RPC and the test. - -**Verify**: `pnpm test:unit -- storage` → all pass. - -### Step 4: Lint - -**Verify**: `pnpm lint` → exit 0 (`pnpm lint --fix` if needed). - -## Test plan - -- New file `packages/devtools/test/storage-denylist.test.ts` covering denied mounts - and allowed user mounts. -- Verification: `pnpm test:unit` → all pass. - -## Done criteria - -- [ ] `shouldIgnoreStorageKey` (or the extracted module) is exported and imported by the test. -- [ ] `getStorageItem`/`setStorageItem`/`removeStorageItem` each reject denied keys. -- [ ] `pnpm test:unit` exits 0; the new storage test passes. -- [ ] `pnpm lint` exits 0. -- [ ] Only the in-scope files are modified/created (`git status`). -- [ ] `plans/README.md` status row for 006 updated. - -## STOP conditions - -Stop and report if: - -- `storage.ts` doesn't match the "Current state" excerpt (drift). -- The DevTools storage UI legitimately needs to read one of the denied mounts (i.e. - gating breaks an intended feature) — report the call site rather than removing the - guard. -- A verification fails twice after a reasonable fix attempt. - -## Maintenance notes - -- If future work needs to surface a denied mount read-only, prefer an explicit - allowlist toggle over removing the guard. -- Reviewer: confirm the guard is applied on all three item operations and that - `getStorageKeys`'s existing filter is unchanged. diff --git a/plans/README.md b/plans/README.md index e63067444f..bfeb833dc7 100644 --- a/plans/README.md +++ b/plans/README.md @@ -14,8 +14,8 @@ when done. | 002 | Add a root AGENTS.md | P2 | S | — | DONE | | 003 | Fix `uninstallNuxtModule` (removes wrong module → config data loss) | P1 | S | 001 | DONE | | 004 | Fix options RPC cache (inverted guard + shared-constant mutation) | P1 | S | 001 | DONE | -| 005 | Harden assets RPC (path containment + collision-filename bug) | P1 | S–M | 001 | TODO | -| 006 | Enforce storage denylist on item access (not just listing) | P1 | S | 001 | TODO | +| 005 | Harden assets RPC (path containment + collision-filename bug) | P1 | S–M | 001 | DONE | +| 006 | Enforce storage denylist on item access (not just listing) | P1 | S | 001 | DONE | | 007 | Fix fn-metric metadata + server-data `environments` bug | P2 | S | 001 | TODO | | 008 | Fix v4 migration guide `enablePages` example | P2 | S | — | TODO | | 009 | Remove dead auth-migration code | P2 | S | 001 | TODO |