Skip to content

Commit dd22149

Browse files
committed
fix(docs): reject unsafe tarball members before extraction
1 parent 53f3342 commit dd22149

1 file changed

Lines changed: 24 additions & 1 deletion

File tree

packages/nuxt-cli/src/utils/docs-index.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ async function downloadIndex(cwd: string, version: string, options: DocsIndexPro
158158
}
159159
const archive = join(staging, 'docs.tgz')
160160
writeFileSync(archive, tarball)
161+
assertSafeArchiveMembers(archive)
161162
// `tar` ships with macOS, Linux and Windows 10 onwards. The archive's
162163
// `package/` wrapper is stripped so paths match an installed copy.
163164
execFileSync('tar', ['-xzf', archive, '-C', staging, '--strip-components=1'], { stdio: 'ignore' })
@@ -173,6 +174,24 @@ async function downloadIndex(cwd: string, version: string, options: DocsIndexPro
173174
}
174175
}
175176

177+
/**
178+
* Refuse an archive whose member paths could escape the extraction directory:
179+
* absolute paths, drive letters, or `..` segments. The registry tarball is
180+
* still a download, and `tar`'s own handling of such members varies across
181+
* the system implementations this relies on.
182+
*/
183+
function assertSafeArchiveMembers(archive: string): void {
184+
const listing = execFileSync('tar', ['-tzf', archive], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 * 1024 * 1024 })
185+
for (const member of listing.split('\n')) {
186+
if (!member) {
187+
continue
188+
}
189+
if (member.startsWith('/') || member.startsWith('\\') || /^[a-z]:/i.test(member) || member.split(/[\\/]/).includes('..')) {
190+
throw new Error(`Refusing to extract unsafe archive member \`${member}\``)
191+
}
192+
}
193+
}
194+
176195
/**
177196
* The `@nuxt/docs` tarball from the configured registry, falling back to npm
178197
* itself: the docs are a public package, so a proxy that rejects this process is
@@ -277,13 +296,17 @@ function writeCache(nuxtVersion: string | undefined, index: DocsIndex): void {
277296
}
278297
}
279298

299+
/**
300+
* Only regular files and directories are considered: a symlink in the archive
301+
* would otherwise be read through, reaching outside the extracted tree.
302+
*/
280303
function* markdownFiles(dir: string): Generator<string> {
281304
for (const item of readdirSync(dir, { withFileTypes: true })) {
282305
const path = join(dir, item.name)
283306
if (item.isDirectory()) {
284307
yield* markdownFiles(path)
285308
}
286-
else if (item.name.endsWith('.md') && item.name !== 'README.md') {
309+
else if (item.isFile() && item.name.endsWith('.md') && item.name !== 'README.md') {
287310
yield path
288311
}
289312
}

0 commit comments

Comments
 (0)