fix(security): add archive extraction containment validation

Validate tar and zip entries before extraction to prevent path traversal:
- Reject absolute paths in archives
- Reject .. traversal paths
- Reject symlinks pointing outside extraction dir
- New archive-entry-validator module with comprehensive tests

Addresses: security audit finding for unsafe archive extraction.
This commit is contained in:
YeonGyu-Kim
2026-04-02 15:01:15 +09:00
parent a637cca702
commit d861d51959
5 changed files with 454 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
export type ArchiveEntry = {
path: string
type: "file" | "directory" | "symlink"
linkPath?: string
}
function normalizeArchivePath(filePath: string): string {
return filePath.replaceAll("\\", "/")
}
function containsTraversalSegment(filePath: string): boolean {
return normalizeArchivePath(filePath)
.split("/")
.some(segment => segment === "..")
}
function isArchiveAbsolutePath(filePath: string): boolean {
const normalizedPath = normalizeArchivePath(filePath)
return isAbsolute(normalizedPath) || /^[A-Za-z]:\//.test(normalizedPath) || normalizedPath.startsWith("//")
}
function escapesDirectory(rootDir: string, candidatePath: string): boolean {
const relativePath = relative(rootDir, candidatePath)
return relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)
}
function resolveContainedPath(rootDir: string, filePath: string, errorLabel: string): string {
const normalizedPath = normalizeArchivePath(filePath)
if (isArchiveAbsolutePath(normalizedPath)) {
throw new Error(`Unsafe archive entry: ${errorLabel} uses an absolute path (${filePath})`)
}
if (containsTraversalSegment(normalizedPath)) {
throw new Error(`Unsafe archive entry: ${errorLabel} contains path traversal (${filePath})`)
}
const resolvedPath = resolve(rootDir, normalizedPath)
if (escapesDirectory(rootDir, resolvedPath)) {
throw new Error(`Unsafe archive entry: ${errorLabel} contains path traversal (${filePath})`)
}
return resolvedPath
}
export function validateArchiveEntries(entries: ArchiveEntry[], destDir: string): void {
const resolvedDestDir = resolve(destDir)
for (const entry of entries) {
const resolvedEntryPath = resolveContainedPath(resolvedDestDir, entry.path, "path")
if (entry.type !== "symlink") {
continue
}
if (!entry.linkPath) {
throw new Error(`Unsafe archive entry: symlink target missing for ${entry.path}`)
}
const normalizedLinkPath = normalizeArchivePath(entry.linkPath)
if (isArchiveAbsolutePath(normalizedLinkPath)) {
throw new Error(`Unsafe archive entry: symlink target uses an absolute path (${entry.linkPath})`)
}
if (containsTraversalSegment(normalizedLinkPath)) {
throw new Error(`Unsafe archive entry: symlink target contains path traversal (${entry.linkPath})`)
}
const resolvedLinkPath = resolve(dirname(resolvedEntryPath), normalizedLinkPath)
if (escapesDirectory(resolvedDestDir, resolvedLinkPath)) {
throw new Error(`Unsafe archive entry: symlink target escapes extraction directory (${entry.linkPath})`)
}
}
}