| 1 | import type { Plugin, ResolvedConfig } from 'vite' |
| 2 | import { existsSync, realpathSync } from 'node:fs' |
| 3 | import { fileURLToPath } from 'node:url' |
| 4 | import path from 'pathe' |
| 5 | import { parseSync } from 'vite' |
| 6 | import { templateLegacyTitles } from '../virtual/deprecated' |
| 7 | import { templateTitleRendererMd } from '../virtual/titles' |
| 8 | import { regexSlideSourceId } from './common' |
| 9 | |
| 10 | interface ImportSource { |
| 11 | value: string |
| 12 | start?: number |
| 13 | } |
| 14 | |
| 15 | const virtualSlideMarkdownIds = new Set([ |
| 16 | templateTitleRendererMd.id, |
| 17 | templateLegacyTitles.id, |
| 18 | ]) |
| 19 | |
| 20 | export function createSlideImportGuardPlugin(): Plugin { |
| 21 | let config: ResolvedConfig |
| 22 | let allowRoots: string[] = [] |
| 23 | |
| 24 | return { |
| 25 | name: 'slidev:slide-import-guard', |
| 26 | |
| 27 | configResolved(resolved) { |
| 28 | config = resolved |
| 29 | allowRoots = config.server.fs.allow.map(normalizeFsPath) |
| 30 | }, |
| 31 | |
| 32 | async transform(code, id) { |
| 33 | if (!isSlideMarkdownId(id) || !config?.server.fs.strict) |
| 34 | return null |
| 35 | |
| 36 | const importer = filePathFromId(id) ?? id |
| 37 | await Promise.all(extractImportSources(code, id).map(async ({ value, start }) => { |
| 38 | // Public-directory assets are referenced by root-absolute URL (`/foo.png`) |
| 39 | // and served by Vite's public-dir middleware, not module-resolved. Such a |
| 40 | // URL is a Vite URL, not a filesystem-absolute path, so exempt it from the |
| 41 | // fs.allow check when it maps to an existing file under `config.publicDir`. |
| 42 | if (isPublicAsset(value, config)) |
| 43 | return |
| 44 | |
| 45 | const resolved = await this.resolve(value, importer, { skipSelf: true }) |
| 46 | if (!resolved || resolved.external) |
| 47 | return |
| 48 | |
| 49 | const filePath = filePathFromId(resolved.id) |
| 50 | if (!filePath) |
| 51 | return |
| 52 | |
| 53 | const normalized = normalizeFsPath(filePath) |
| 54 | if (isAllowedFile(normalized, allowRoots)) |
| 55 | return |
| 56 | if (isBareImport(value) && isDependencyFile(normalized)) |
| 57 | return |
| 58 | |
| 59 | this.error( |
| 60 | `[slidev] Import "${value}" from slide Markdown resolves outside of Vite server.fs.allow: ${normalized}`, |
| 61 | start, |
| 62 | ) |
| 63 | })) |
| 64 | |
| 65 | return null |
| 66 | }, |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | export function isSlideMarkdownId(id: string) { |
| 71 | const clean = cleanUrl(id) |
| 72 | return regexSlideSourceId.test(clean) || virtualSlideMarkdownIds.has(clean) |
| 73 | } |
| 74 | |
| 75 | export function filePathFromId(id: string): string | null { |
| 76 | const clean = cleanUrl(id) |
| 77 | if (clean.startsWith('file://')) |
| 78 | return fileURLToPath(clean) |
| 79 | if (clean.startsWith('/@fs/')) |
| 80 | return clean.slice('/@fs'.length) |
| 81 | if (clean.startsWith('/@')) |
| 82 | return null |
| 83 | if (path.isAbsolute(clean)) |
| 84 | return clean |
| 85 | return null |
| 86 | } |
| 87 | |
| 88 | export function isAllowedFile(filePath: string, allowRoots: string[]) { |
| 89 | return allowRoots.some(root => isFileInRoot(root, filePath)) |
| 90 | } |
| 91 | |
| 92 | /** |
| 93 | * Whether a static import `value` is a public-directory asset, i.e. a |
| 94 | * root-absolute Vite URL (`/foo.png`) that maps to an existing file under |
| 95 | * `config.publicDir`. The Vue SFC compiler turns `<img src="/foo.png">` into a |
| 96 | * static `import`, but public assets are served by URL rather than |
| 97 | * module-resolved, so `/foo.png` is a Vite URL — not a filesystem-absolute path |
| 98 | * — and must be exempted from the `server.fs.allow` check. |
| 99 | */ |
| 100 | export function isPublicAsset(value: string, config: Pick<ResolvedConfig, 'publicDir'>): boolean { |
| 101 | // `publicDir` is `''` when disabled (`publicDir: false`). Vite treats `/@fs/`, |
| 102 | // `/@id/`, … as internal URLs, never public assets, so leave those alone. |
| 103 | if (!config.publicDir || !value.startsWith('/') || value.startsWith('/@')) |
| 104 | return false |
| 105 | const publicDir = normalizeFsPath(config.publicDir) |
| 106 | const publicPath = normalizeFsPath(path.join(publicDir, cleanUrl(value).slice(1))) |
| 107 | return isFileInRoot(publicDir, publicPath) && existsSync(publicPath) |
| 108 | } |
| 109 | |
| 110 | export function extractImportSources(code: string, id: string): ImportSource[] { |
| 111 | const result = parseSync(id, code) |
| 112 | const sources: ImportSource[] = [] |
| 113 | |
| 114 | for (const item of result.module.staticImports) { |
| 115 | sources.push({ |
| 116 | value: item.moduleRequest.value, |
| 117 | start: item.moduleRequest.start, |
| 118 | }) |
| 119 | } |
| 120 | |
| 121 | for (const item of result.module.staticExports) { |
| 122 | for (const entry of item.entries) { |
| 123 | if (entry.moduleRequest) { |
| 124 | sources.push({ |
| 125 | value: entry.moduleRequest.value, |
| 126 | start: entry.moduleRequest.start, |
| 127 | }) |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | for (const item of result.module.dynamicImports) { |
| 133 | const source = parseStringLiteral(code.slice(item.moduleRequest.start, item.moduleRequest.end)) |
| 134 | if (source) { |
| 135 | sources.push({ |
| 136 | value: source, |
| 137 | start: item.moduleRequest.start, |
| 138 | }) |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | return sources |
| 143 | } |
| 144 | |
| 145 | function cleanUrl(id: string) { |
| 146 | return id.replace(/[?#].*$/, '') |
| 147 | } |
| 148 | |
| 149 | function normalizeFsPath(filePath: string) { |
| 150 | const absolute = path.resolve(filePath) |
| 151 | if (!existsSync(absolute)) |
| 152 | return normalizeMissingFsPath(absolute) |
| 153 | return realpathSync.native(absolute) |
| 154 | } |
| 155 | |
| 156 | function normalizeMissingFsPath(filePath: string): string { |
| 157 | const dir = path.dirname(filePath) |
| 158 | if (dir === filePath) |
| 159 | return filePath |
| 160 | if (existsSync(dir)) |
| 161 | return path.join(realpathSync.native(dir), path.basename(filePath)) |
| 162 | return path.join(normalizeMissingFsPath(dir), path.basename(filePath)) |
| 163 | } |
| 164 | |
| 165 | function isFileInRoot(root: string, filePath: string) { |
| 166 | const relative = path.relative(root, filePath) |
| 167 | return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative)) |
| 168 | } |
| 169 | |
| 170 | function isDependencyFile(filePath: string) { |
| 171 | return filePath.split(/[\\/]/).includes('node_modules') |
| 172 | } |
| 173 | |
| 174 | function isBareImport(source: string) { |
| 175 | return /^(?![a-z]:)[\w@](?!.*:\/\/)/i.test(source) |
| 176 | } |
| 177 | |
| 178 | function parseStringLiteral(raw: string) { |
| 179 | const trimmed = raw.trim() |
| 180 | const quote = trimmed[0] |
| 181 | if (quote !== '"' && quote !== '\'' && quote !== '`') |
| 182 | return null |
| 183 | if (trimmed.at(-1) !== quote) |
| 184 | return null |
| 185 | if (quote === '`' && trimmed.includes('${')) |
| 186 | return null |
| 187 | |
| 188 | try { |
| 189 | if (quote === '"') |
| 190 | return JSON.parse(trimmed) as string |
| 191 | return JSON.parse(`"${trimmed.slice(1, -1).replace(/"/g, '\\"')}"`) as string |
| 192 | } |
| 193 | catch { |
| 194 | return trimmed.slice(1, -1) |
| 195 | } |
| 196 | } |
| 197 |