fix: validate extraction ownership before writes

Reject symlink and reparse boundaries before internal ZIP or JVM outputs are opened, track opened, committed, partial, removed output lifecycle events, and convert output callback failures into controlled extractor failures without poisoning the JVM daemon. Gate legacy recovery behind an atomically created package-generation owner marker and keep unmarked reused or shared directories fail closed. Parse native RAR output from strictly verified locale-independent candidates while retaining ambiguous rename rejection.
This commit is contained in:
Sucukdeluxe
2026-08-22 15:24:19 +02:00
parent 58237bed0c
commit 11d63dd2be
21 changed files with 1010 additions and 378 deletions
+152 -51
View File
@@ -119,6 +119,13 @@ export class ExtractionError extends Error {
this.name = "ExtractionError";
}
}
class ExtractionOutputCallbackError extends Error {
public constructor(error: unknown) {
super(`extract_output_callback_failed: ${cleanErrorText(String(error))}`);
this.name = "ExtractionOutputCallbackError";
}
}
type ExtractionErrorWithHints = Error & {
suggestRedownload?: boolean;
@@ -146,6 +153,15 @@ type JvmExtractResult = {
backend: string;
};
type JvmParseState = {
bestPercent: number;
usedPassword: string;
backend: string;
reportedError: string;
outputError?: Error;
openedOutputs?: Map<string, ExtractOutputEvent>;
};
export interface ExtractResult {
extracted: number;
failed: number;
@@ -193,7 +209,7 @@ interface DaemonRequest {
onArchiveProgress?: (percent: number) => void;
signal?: AbortSignal;
timeoutMs?: number;
parseState: { bestPercent: number; usedPassword: string; backend: string; reportedError: string };
parseState: JvmParseState;
archiveName: string;
startedAt: number;
passwordCount: number;
@@ -1556,7 +1572,7 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
function parseJvmLine(
line: string,
onArchiveProgress: ((percent: number) => void) | undefined,
state: { bestPercent: number; usedPassword: string; backend: string; reportedError: string },
state: JvmParseState,
onOutput?: (event: ExtractOutputEvent) => void
): void {
const trimmed = String(line || "").trim();
@@ -1597,18 +1613,33 @@ function parseJvmLine(
const disposition = fields[3];
if (fields.length !== 7
|| fields[1] !== "1"
|| (stateValue !== "complete" && stateValue !== "partial")
|| !(["opened", "complete", "partial", "removed"] as const).includes(stateValue as ExtractOutputEvent["state"])
|| !(["written", "overwritten", "renamed", "skipped"] as const).includes(disposition as ExtractOutputEvent["disposition"])) {
return;
}
onOutput?.({
const event: ExtractOutputEvent = {
version: 1,
archivePath: Buffer.from(fields[4], "base64").toString("utf8"),
entryPath: Buffer.from(fields[5], "base64").toString("utf8"),
outputPath: Buffer.from(fields[6], "base64").toString("utf8"),
state: stateValue,
state: stateValue as ExtractOutputEvent["state"],
disposition: disposition as ExtractOutputEvent["disposition"]
});
};
const outputKey = pathSetKey(path.resolve(event.outputPath));
state.openedOutputs ||= new Map<string, ExtractOutputEvent>();
if (event.state === "opened") {
state.openedOutputs.set(outputKey, event);
} else if (event.state === "complete" || event.state === "removed") {
state.openedOutputs.delete(outputKey);
}
if (!state.outputError) {
try {
onOutput?.(event);
} catch (error) {
state.outputError = error instanceof Error ? error : new Error(String(error));
state.reportedError = state.outputError.message;
}
}
return;
}
@@ -1661,7 +1692,7 @@ function finishDaemonRequest(result: JvmExtractResult): void {
req.resolve(result);
}
function flushDaemonParseBuffers(req: DaemonRequest | null): void {
function flushDaemonParseBuffers(req: DaemonRequest | null): void {
if (!req) {
return;
}
@@ -1693,7 +1724,11 @@ function handleDaemonLine(line: string): void {
if (daemonCurrentRequest !== req) {
return;
}
flushDaemonParseBuffers(req);
flushDaemonParseBuffers(req);
if (req.parseState.outputError) {
failDaemonOutputCallback(req);
return;
}
const elapsedMs = Date.now() - req.startedAt;
logger.info(
`JVM Daemon Request Ende: archive=${req.archiveName}, code=${code}, ms=${elapsedMs}, pwCandidates=${req.passwordCount}, ` +
@@ -1727,9 +1762,11 @@ function handleDaemonLine(line: string): void {
return;
}
if (daemonCurrentRequest) {
parseJvmLine(trimmed, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput);
}
if (daemonCurrentRequest) {
const req = daemonCurrentRequest;
parseJvmLine(trimmed, req.onArchiveProgress, req.parseState, req.onOutput);
failDaemonOutputCallback(req);
}
}
function startDaemon(layout: JvmExtractorLayout): boolean {
@@ -1780,9 +1817,11 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
const lines = daemonStderrBuffer.split(/\r?\n/);
daemonStderrBuffer = lines.pop() || "";
for (const line of lines) {
if (daemonCurrentRequest) {
parseJvmLine(line, daemonCurrentRequest.onArchiveProgress, daemonCurrentRequest.parseState, daemonCurrentRequest.onOutput);
}
if (daemonCurrentRequest) {
const req = daemonCurrentRequest;
parseJvmLine(line, req.onArchiveProgress, req.parseState, req.onOutput);
failDaemonOutputCallback(req);
}
}
});
@@ -1999,9 +2038,10 @@ async function runJvmExtractCommand(
let timedOutByWatchdog = false;
let abortedBySignal = false;
let onAbort: (() => void) | null = null;
const parseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
let stdoutBuffer = "";
let stderrBuffer = "";
const parseState: JvmParseState = { bestPercent: 0, usedPassword: "", backend: "", reportedError: "" };
let stdoutBuffer = "";
let stderrBuffer = "";
let outputCallbackKillStarted = false;
const child = spawn(layout.javaCommand, args, { windowsHide: true });
lowerExtractProcessPriority(child.pid, currentExtractCpuPriority);
@@ -2014,9 +2054,13 @@ async function runJvmExtractCommand(
const nextBuffer = `${fromStdErr ? stderrBuffer : stdoutBuffer}${rawChunk}`;
const lines = nextBuffer.split(/\r?\n/);
const keep = lines.pop() || "";
for (const line of lines) {
for (const line of lines) {
parseJvmLine(line, onArchiveProgress, parseState, onOutput);
}
}
if (parseState.outputError && !outputCallbackKillStarted) {
outputCallbackKillStarted = true;
killProcessTree(child);
}
if (fromStdErr) {
stderrBuffer = keep;
} else {
@@ -2101,17 +2145,31 @@ async function runJvmExtractCommand(
});
return;
}
if (timedOutByWatchdog) {
if (timedOutByWatchdog) {
finish({
ok: false, missingCommand: false, missingRuntime: false,
aborted: false, timedOut: true,
errorText: `Entpacken Timeout nach ${Math.ceil((timeoutMs || 0) / 1000)}s`,
usedPassword: parseState.usedPassword, backend: parseState.backend
});
return;
}
const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`;
return;
}
if (parseState.outputError) {
finish({
ok: false,
missingCommand: false,
missingRuntime: false,
aborted: false,
timedOut: false,
errorText: cleanErrorText(parseState.outputError.message || String(parseState.outputError)),
usedPassword: parseState.usedPassword,
backend: parseState.backend
});
return;
}
const message = cleanErrorText(parseState.reportedError || output) || `Exit Code ${String(code ?? "?")}`;
if (code === 0) {
onArchiveProgress?.(100);
finish({
@@ -2171,8 +2229,9 @@ export function parseNativeExtractOutput(
const match = trimmed.match(/^[-+]\s+(.+)$/);
reportedPath = match?.[1]?.trim() || "";
} else if (isRarNativeCommand(command)) {
const match = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i);
reportedPath = match?.[1]?.trim() || "";
const localizedMatch = trimmed.match(/^.+?\s{2,}(.+?)\s{2,}OK$/);
const legacyMatch = trimmed.match(/^Extracting\s+(.+?)(?:\s+OK)?$/i);
reportedPath = localizedMatch?.[1]?.trim() || legacyMatch?.[1]?.trim() || "";
}
if (!reportedPath) {
return [];
@@ -2212,6 +2271,24 @@ export function parseNativeExtractOutput(
}
}
function failDaemonOutputCallback(req: DaemonRequest): void {
if (daemonCurrentRequest !== req || !req.parseState.outputError) {
return;
}
const message = cleanErrorText(req.parseState.outputError.message || String(req.parseState.outputError));
finishDaemonRequest({
ok: false,
missingCommand: false,
missingRuntime: false,
aborted: false,
timedOut: false,
errorText: message,
usedPassword: req.parseState.usedPassword,
backend: req.parseState.backend
});
shutdownDaemon();
}
function createNativeOutputCollector(
command: string,
archivePath: string,
@@ -2224,7 +2301,7 @@ function createNativeOutputCollector(
const collectLine = (value: string): void => {
const trimmed = value.trim();
if ((extractorCommandKind(command) === "seven_zip" && /^[-+]\s+/.test(trimmed))
|| (isRarNativeCommand(command) && /^Extracting\s+/i.test(trimmed))) {
|| (isRarNativeCommand(command) && (/^.+?\s{2,}.+?\s{2,}OK$/.test(trimmed) || /^Extracting\s+/i.test(trimmed)))) {
lines.add(trimmed);
}
};
@@ -2697,9 +2774,12 @@ async function runExternalExtract(
function isZipSafetyGuardError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("path traversal")
|| text.includes("zip-eintrag verdächtig groß")
|| text.includes("zip-eintrag verdaechtig gross");
return text.includes("path traversal")
|| text.includes("zip-eintrag verdächtig groß")
|| text.includes("zip-eintrag verdaechtig gross")
|| text.includes("symbolischer link")
|| text.includes("reparse point")
|| text.includes("extract_output_callback_failed");
}
function isZipInternalLimitError(error: unknown): boolean {
@@ -2736,7 +2816,8 @@ async function extractZipArchive(
targetDir: string,
conflictMode: ConflictMode,
signal?: AbortSignal,
onOutput?: (event: ExtractOutputEvent) => void
onOutput?: (event: ExtractOutputEvent) => void,
validateTarget?: (entryPath: string, outputPath: string) => void
): Promise<void> {
const mode = effectiveConflictMode(conflictMode);
const memoryLimitBytes = zipEntryMemoryLimitBytes();
@@ -2755,9 +2836,11 @@ async function extractZipArchive(
logger.warn(`ZIP-Eintrag übersprungen (Path Traversal): ${entry.entryName}`);
continue;
}
if (entry.isDirectory) {
await fs.promises.mkdir(baseOutputPath, { recursive: true });
continue;
if (entry.isDirectory) {
validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath);
await fs.promises.mkdir(baseOutputPath, { recursive: true });
validateTarget?.(entry.entryName.replace(/\\/g, "/").replace(/\/$/, "") || "directory", baseOutputPath);
continue;
}
const header = (entry as unknown as {
@@ -2797,7 +2880,6 @@ async function extractZipArchive(
let outputKey = pathSetKey(outputPath);
let disposition: ExtractOutputEvent["disposition"] = "written";
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
if (outputExists) {
if (mode === "skip") {
@@ -2840,10 +2922,22 @@ async function extractZipArchive(
}
}
if (signal?.aborted) {
throw new Error("aborted:extract");
}
const data = entry.getData();
if (signal?.aborted) {
throw new Error("aborted:extract");
}
const normalizedEntryPath = entry.entryName.replace(/\\/g, "/");
validateTarget?.(normalizedEntryPath, outputPath);
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: normalizedEntryPath,
outputPath,
state: "opened",
disposition
});
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
validateTarget?.(normalizedEntryPath, outputPath);
const data = entry.getData();
if (data.length > memoryLimitBytes) {
const entryMb = Math.ceil(data.length / (1024 * 1024));
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
@@ -2856,14 +2950,6 @@ async function extractZipArchive(
try {
await fs.promises.writeFile(outputPath, data);
usedOutputs.add(outputKey);
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: entry.entryName.replace(/\\/g, "/"),
outputPath,
state: "complete",
disposition
});
} catch (error) {
if (await fs.promises.access(outputPath).then(() => true, () => false)) {
onOutput?.({
@@ -2877,6 +2963,14 @@ async function extractZipArchive(
}
throw error;
}
onOutput?.({
version: 1,
archivePath: path.resolve(archivePath),
entryPath: normalizedEntryPath,
outputPath,
state: "complete",
disposition
});
}
}
@@ -3149,7 +3243,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const outputScope = new PackageOutputScope([options.targetDir]);
const emitOutput = (event: ExtractOutputEvent): void => {
outputScope.add(event);
options.onOutput?.(event);
try {
options.onOutput?.(event);
} catch (error) {
throw new ExtractionOutputCallbackError(error);
}
};
const validateOutputTarget = (entryPath: string, outputPath: string): void => {
outputScope.validateTarget(entryPath, outputPath);
};
options.onProgress?.({ current: 0, total: 0, percent: 0, archiveName: "Archive scannen...", phase: "preparing" });
const allCandidates = await findArchiveCandidates(options.packageDir);
@@ -3428,14 +3529,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
rememberLearnedPassword(usedPassword);
} catch (error) {
if (isNoExtractorError(String(error))) {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput);
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
} else {
throw error;
}
}
} else {
try {
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput);
await extractZipArchive(archivePath, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
archivePercent = 100;
} catch (error) {
if (!shouldFallbackToExternalZip(error)) {
@@ -3696,7 +3797,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const ext = path.extname(nestedArchive).toLowerCase();
if (ext === ".zip" && !(await shouldPreferExternalZip(nestedArchive))) {
try {
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput);
await extractZipArchive(nestedArchive, options.targetDir, options.conflictMode, options.signal, emitOutput, validateOutputTarget);
nestedPercent = 100;
} catch (zipErr) {
if (!shouldFallbackToExternalZip(zipErr)) throw zipErr;