fix(security): harden tar archive extraction parity

This commit is contained in:
Peter Steinberger
2026-03-02 16:36:46 +00:00
parent 17ede52a4b
commit 0dbb92dd2b
4 changed files with 277 additions and 80 deletions

View File

@@ -176,23 +176,30 @@ describe("archive utils", () => {
},
);
it("rejects archives that exceed archive size budget", async () => {
await withArchiveCase("zip", async ({ archivePath, extractDir }) => {
const zip = new JSZip();
zip.file("package/file.txt", "ok");
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
const stat = await fs.stat(archivePath);
await expect(
extractArchive({
it.each([{ ext: "zip" as const }, { ext: "tar" as const }])(
"rejects $ext archives that exceed archive size budget",
async ({ ext }) => {
await withArchiveCase(ext, async ({ workDir, archivePath, extractDir }) => {
await writePackageArchive({
ext,
workDir,
archivePath,
destDir: extractDir,
timeoutMs: 5_000,
limits: { maxArchiveBytes: Math.max(1, stat.size - 1) },
}),
).rejects.toThrow("archive size exceeds limit");
});
});
fileName: "file.txt",
content: "ok",
});
const stat = await fs.stat(archivePath);
await expect(
extractArchive({
archivePath,
destDir: extractDir,
timeoutMs: 5_000,
limits: { maxArchiveBytes: Math.max(1, stat.size - 1) },
}),
).rejects.toThrow("archive size exceeds limit");
});
},
);
it("fails resolvePackedRootDir when extract dir has multiple root dirs", async () => {
const workDir = await makeTempDir("packed-root");

View File

@@ -21,8 +21,7 @@ export type ArchiveLogger = {
export type ArchiveExtractLimits = {
/**
* Max archive file bytes (compressed). Primarily protects zip extraction
* because we currently read the whole archive into memory for parsing.
* Max archive file bytes (compressed).
*/
maxArchiveBytes?: number;
/** Max number of extracted entries (files + dirs). */
@@ -457,7 +456,16 @@ async function extractZip(params: {
}
}
type TarEntryInfo = { path: string; type: string; size: number };
export type TarEntryInfo = { path: string; type: string; size: number };
const BLOCKED_TAR_ENTRY_TYPES = new Set([
"SymbolicLink",
"Link",
"BlockDevice",
"CharacterDevice",
"FIFO",
"Socket",
]);
function readTarEntryInfo(entry: unknown): TarEntryInfo {
const p =
@@ -479,6 +487,42 @@ function readTarEntryInfo(entry: unknown): TarEntryInfo {
return { path: p, type: t, size: s };
}
export function createTarEntrySafetyChecker(params: {
rootDir: string;
stripComponents?: number;
limits?: ArchiveExtractLimits;
escapeLabel?: string;
}): (entry: TarEntryInfo) => void {
const strip = Math.max(0, Math.floor(params.stripComponents ?? 0));
const limits = resolveExtractLimits(params.limits);
let entryCount = 0;
const budget = createByteBudgetTracker(limits);
return (entry: TarEntryInfo) => {
validateArchiveEntryPath(entry.path, { escapeLabel: params.escapeLabel });
const relPath = stripArchivePath(entry.path, strip);
if (!relPath) {
return;
}
validateArchiveEntryPath(relPath, { escapeLabel: params.escapeLabel });
resolveArchiveOutputPath({
rootDir: params.rootDir,
relPath,
originalPath: entry.path,
escapeLabel: params.escapeLabel,
});
if (BLOCKED_TAR_ENTRY_TYPES.has(entry.type)) {
throw new Error(`tar entry is a link: ${entry.path}`);
}
entryCount += 1;
assertArchiveEntryCountWithinLimit(entryCount, limits);
budget.addEntrySize(entry.size);
};
}
export async function extractArchive(params: {
archivePath: string;
destDir: string;
@@ -496,49 +540,28 @@ export async function extractArchive(params: {
const label = kind === "zip" ? "extract zip" : "extract tar";
if (kind === "tar") {
const strip = Math.max(0, Math.floor(params.stripComponents ?? 0));
const limits = resolveExtractLimits(params.limits);
let entryCount = 0;
const budget = createByteBudgetTracker(limits);
const stat = await fs.stat(params.archivePath);
if (stat.size > limits.maxArchiveBytes) {
throw new Error(ERROR_ARCHIVE_SIZE_EXCEEDS_LIMIT);
}
const checkTarEntrySafety = createTarEntrySafetyChecker({
rootDir: params.destDir,
stripComponents: params.stripComponents,
limits,
});
await withTimeout(
tar.x({
file: params.archivePath,
cwd: params.destDir,
strip,
strip: Math.max(0, Math.floor(params.stripComponents ?? 0)),
gzip: params.tarGzip,
preservePaths: false,
strict: true,
onReadEntry(entry) {
const info = readTarEntryInfo(entry);
try {
validateArchiveEntryPath(info.path);
const relPath = stripArchivePath(info.path, strip);
if (!relPath) {
return;
}
validateArchiveEntryPath(relPath);
resolveArchiveOutputPath({
rootDir: params.destDir,
relPath,
originalPath: info.path,
});
if (
info.type === "SymbolicLink" ||
info.type === "Link" ||
info.type === "BlockDevice" ||
info.type === "CharacterDevice" ||
info.type === "FIFO" ||
info.type === "Socket"
) {
throw new Error(`tar entry is a link: ${info.path}`);
}
entryCount += 1;
assertArchiveEntryCountWithinLimit(entryCount, limits);
budget.addEntrySize(info.size);
checkTarEntrySafety(readTarEntryInfo(entry));
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
// Node's EventEmitter calls listeners with `this` bound to the