Repository navigation
fix(devtools): contain asset RPC paths and the storage denylist to what the UI may touch #1111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
644f385
fix(deps): align the Nuxt nightly with nitro 3.0.260903 so the dev se…
antfubot 6932dca
fix(devtools): keep typecheck green with the current Nuxt nightly
antfubot 08a75a5
fix(build): keep the built module when the client assets are generated
antfubot b7a60a4
fix(devtools): contain asset RPC paths and the storage denylist to wh…
antfubot 57499f8
Merge branch 'main' into fix/harden-assets-and-storage-rpc
antfu 0f93716
fix(devtools): close key-spelling and symlink bypasses in the RPC den…
antfubot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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([]) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.