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
+55
View File
@@ -1,6 +1,7 @@
import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
import * as path from "node:path";
import { spawn } from "bun";
import { validateArchiveEntries, type ArchiveEntry } from "./archive-entry-validator";
import { extractZip } from "./zip-extractor";
export function getCachedBinaryPath(cacheDir: string, binaryName: string): string | null {
@@ -29,6 +30,9 @@ export async function extractTarGz(
destDir: string,
options?: { args?: string[]; cwd?: string }
): Promise<void> {
const entries = await listTarEntries(archivePath, options?.cwd)
validateArchiveEntries(entries, destDir)
const args = options?.args ?? ["tar", "-xzf", archivePath, "-C", destDir];
const proc = spawn(args, {
cwd: options?.cwd,
@@ -58,3 +62,54 @@ export function ensureExecutable(binaryPath: string): void {
chmodSync(binaryPath, 0o755);
}
}
function parseTarEntry(line: string): ArchiveEntry | null {
const match = line.match(/^([^\s])\S*\s+\d+\s+\S+\s+\S+\s+\d+\s+\w+\s+\d+\s+(?:\d{2}:\d{2}|\d{4})\s+(.*)$/)
if (!match) {
return null
}
const [, rawType, rawEntryPath] = match
if (rawType === "l") {
const arrowIndex = rawEntryPath.lastIndexOf(" -> ")
if (arrowIndex === -1) {
return { path: rawEntryPath, type: "symlink" }
}
return {
path: rawEntryPath.slice(0, arrowIndex),
type: "symlink",
linkPath: rawEntryPath.slice(arrowIndex + 4),
}
}
return {
path: rawEntryPath,
type: rawType === "d" ? "directory" : "file",
}
}
async function listTarEntries(archivePath: string, cwd?: string): Promise<ArchiveEntry[]> {
const proc = spawn(["tar", "-tvzf", archivePath], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
if (exitCode !== 0) {
throw new Error(`tar entry listing failed (exit ${exitCode}): ${stderr}`)
}
return stdout
.split(/\r?\n/)
.map(line => line.trim())
.filter(Boolean)
.map(line => parseTarEntry(line))
.filter((entry): entry is ArchiveEntry => entry !== null)
}