plans/016-confine-export-output-path.md
exportFilenameExecutor instructions: Follow this plan step by step. Run every verification command and confirm the expected result. If anything in "STOP conditions" occurs, stop and report. When done, update the status row in
plans/README.md. Security-hardening change: code + tests only.Drift check (run first):
git diff --stat c63cb120..HEAD -- packages/slidev/node/commands/export.ts packages/slidev/node/commands/build.tsOn a mismatch with the excerpts below, treat it as a STOP condition.
c63cb120, 2026-07-10The export output filename can come from deck config (exportFilename),
which is attacker-controlled if the deck is untrusted. It is written after only
appending an extension, so a traversing value (e.g. escaping the intended output
directory) causes slidev export / slidev build --download to write the
generated artifact outside where the operator expects. An explicit CLI
--output is operator-supplied and trusted; the deck-config fallback is what
needs constraining to a basename.
packages/slidev/node/commands/export.ts:603 (in getExportOptions):
outFilename = output || outFilename || options.data.config.exportFilename || `${path.basename(entry, '.md')}-export`
return { output: outFilename, /* ... */ }
Here output is the CLI --output arg (trusted) and exportFilename is deck
config (untrusted). The returned output is later written by the gen*
functions (export.ts:388,417,442,498,538) and by commands/build.ts:149-153:
const filename = options.data.config.exportFilename || 'slidev-exported'
await exportSlides({ port, base: config.base, ...getExportOptions(args, options, join(outDir, `${filename}.pdf`)) })
| Purpose | Command | Expected |
|---|---|---|
| Install | pnpm install | exit 0 |
| Build | pnpm build | exit 0 |
| Test | pnpm test -- export (new) | pass |
| Typecheck | pnpm typecheck | exit 0 |
In scope:
packages/slidev/node/commands/export.ts (sanitize the deck-config filename)packages/slidev/node/commands/build.ts (sanitize the download filename)Out of scope:
--output path (operator-supplied, trusted — leave it able to target
any directory the operator chooses).fix/confine-export-filename.fix(security): treat deck exportFilename as a basename.Add a small exported helper (e.g. in export.ts or node/utils.ts):
import path from 'node:path'
// Deck-controlled filenames must not contain directory components.
export function sanitizeExportBasename(name: string): string {
return path.basename(name)
}
path.basename strips any directory portion (../../x → x,
/etc/foo → foo), which is the correct constraint for a deck-provided name.
getExportOptionsOnly sanitize the deck-config source, not the CLI --output:
const deckName = options.data.config.exportFilename
? sanitizeExportBasename(options.data.config.exportFilename)
: undefined
outFilename = output || outFilename || deckName || `${path.basename(entry, '.md')}-export`
build.ts --downloadconst filename = options.data.config.exportFilename
? sanitizeExportBasename(options.data.config.exportFilename)
: 'slidev-exported'
(The join(outDir, ...) then keeps it inside outDir.)
Add packages/slidev/node/commands/export.test.ts (or extend an existing test):
import { describe, expect, it } from 'vitest'
import { sanitizeExportBasename } from './export'
describe('sanitizeExportBasename', () => {
it('keeps a plain name', () => expect(sanitizeExportBasename('talk')).toBe('talk'))
it('strips directory traversal', () => expect(sanitizeExportBasename('../../talk')).toBe('talk'))
it('strips absolute dirs', () => expect(sanitizeExportBasename('/etc/talk')).toBe('talk'))
})
Verify: pnpm build && pnpm test -- export passes.
exportFilename: my-talk still yields my-talk.pdf in the
expected location (no behavior change on the happy path).--output ./some/dir/name still works (operator path untouched).exportFilename is reduced to a basename before use in both export.ts and build.ts--output behavior is unchanged (can still target any directory)pnpm build && pnpm typecheck exit 0git status)plans/README.md status row updatedStop and report if:
exportFilename containing a subdirectory
(search docs/tests) — if so, the fix should resolve-and-assert-within-outDir
instead of basename-stripping; report before changing approach.