75 lines
2.6 KiB
TypeScript
75 lines
2.6 KiB
TypeScript
|
|
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})`)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|