fix(daytona): stream confined archive listings with bounded entries

This commit is contained in:
Dotta committed 2026-09-27 18:25:16 -05:00
1 parent 3564925508
commit 0ec73dfd1b
2 files changed
+228 -33

No files matched your search

@@ -2,7 +2,7 @@ import path from "node:path";
import os from "node:os";
import { promises as fs, createReadStream, createWriteStream } from "node:fs";
import { randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import zlib from "node:zlib";
import { pipeline } from "node:stream/promises";
@@ -250,43 +250,109 @@ export function splitLinkEntryOnce(field: string, delimiter: string): { name: st
* preserved. Parses the `-tvf` verbose listing so both member names and link
* targets are inspected; any unparseable line fails closed.
*/
async function assertTarballEntriesConfined(archivePath: string): Promise<void> {
const { stdout } = await execFileAsync("tar", ["-tvf", archivePath], {
function assertTarListingLineConfined(line: string): void {
if (line.trim().length === 0) return;
const parsed = parseTarVerboseListingLine(line);
if (!parsed) {
throw new Error(`Daytona syncOut refusing tarball with an unparseable entry listing: ${line}`);
}
const typeFlag = parsed.typeFlag;
let name = parsed.rest;
let linkTarget: string | null = null;
if (typeFlag === "l") {
const split = splitLinkEntryOnce(name, " -> ");
if (!split) throw new Error(`Daytona syncOut refusing unparseable or ambiguous symlink entry: ${line}`);
name = split.name;
linkTarget = split.target;
} else if (typeFlag === "h") {
const split = splitLinkEntryOnce(name, " link to ");
if (!split) throw new Error(`Daytona syncOut refusing unparseable or ambiguous hardlink entry: ${line}`);
name = split.name;
linkTarget = split.target;
}
const cleanName = name.replace(/\/+$/, "");
if (cleanName.length > 0 && posixPathEscapes(cleanName)) {
throw new Error(`Daytona syncOut refusing tarball member that escapes the extraction dir: ${name}`);
}
if (linkTarget !== null) {
const resolved = path.posix.join(path.posix.dirname(cleanName), linkTarget);
if (path.posix.isAbsolute(linkTarget) || posixPathEscapes(resolved)) {
throw new Error(
`Daytona syncOut refusing tarball link whose target escapes the extraction dir: ${name} -> ${linkTarget}`,
);
}
}
}
const TAR_LISTING_MAX_LINE_BYTES = 64 * 1024;
const TAR_LISTING_MAX_STDERR_BYTES = 64 * 1024;
const TAR_LISTING_TIMEOUT_MS = 120_000;
export async function assertTarballEntriesConfined(
archivePath: string,
timeoutMs = TAR_LISTING_TIMEOUT_MS,
): Promise<void> {
// Total metadata is unbounded: a valid large workspace can exceed execFile's
// buffer. Bound each entry and diagnostic instead, and inspect every entry
// before extraction starts. Keep bytes until a full line to preserve UTF-8
// characters split across pipe chunks.
const child = spawn("tar", ["-tvf", archivePath], {
env: { ...process.env, COPYFILE_DISABLE: "1" },
maxBuffer: 32 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
const lines = stdout.split("\n").filter((line) => line.trim().length > 0);
for (const line of lines) {
const parsed = parseTarVerboseListingLine(line);
if (!parsed) {
throw new Error(`Daytona syncOut refusing tarball with an unparseable entry listing: ${line}`);
let spawnError: Error | undefined;
let failure: Error | undefined;
let stderr = Buffer.alloc(0);
let pending: Buffer = Buffer.alloc(0);
const closed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once("error", (error) => { spawnError = error; });
child.once("close", (code, signal) => resolve({ code, signal }));
});
const stop = (error: Error) => {
failure ??= error;
child.kill("SIGKILL");
};
const timer = setTimeout(() => {
stop(new Error("Daytona syncOut tar listing validation timed out"));
}, Math.max(1, Math.min(timeoutMs, TAR_LISTING_TIMEOUT_MS)));
child.stderr.on("data", (chunk: Buffer) => {
if (stderr.length + chunk.length > TAR_LISTING_MAX_STDERR_BYTES) {
stop(new Error("Daytona syncOut tar listing diagnostics exceed the byte limit"));
return;
}
const typeFlag = parsed.typeFlag;
let name = parsed.rest;
let linkTarget: string | null = null;
if (typeFlag === "l") {
const split = splitLinkEntryOnce(name, " -> ");
if (!split) throw new Error(`Daytona syncOut refusing unparseable or ambiguous symlink entry: ${line}`);
name = split.name;
linkTarget = split.target;
} else if (typeFlag === "h") {
const split = splitLinkEntryOnce(name, " link to ");
if (!split) throw new Error(`Daytona syncOut refusing unparseable or ambiguous hardlink entry: ${line}`);
name = split.name;
linkTarget = split.target;
}
const cleanName = name.replace(/\/+$/, "");
if (cleanName.length > 0 && posixPathEscapes(cleanName)) {
throw new Error(`Daytona syncOut refusing tarball member that escapes the extraction dir: ${name}`);
}
if (linkTarget !== null) {
const resolved = path.posix.join(path.posix.dirname(cleanName), linkTarget);
if (path.posix.isAbsolute(linkTarget) || posixPathEscapes(resolved)) {
throw new Error(
`Daytona syncOut refusing tarball link whose target escapes the extraction dir: ${name} -> ${linkTarget}`,
);
stderr = Buffer.concat([stderr, chunk]);
});
try {
for await (const chunk of child.stdout) {
if (failure) break;
const bytes = chunk as Buffer;
let start = 0;
while (start < bytes.length) {
const newline = bytes.indexOf(10, start);
const end = newline < 0 ? bytes.length : newline;
if (pending.length + end - start > TAR_LISTING_MAX_LINE_BYTES) {
throw new Error("Daytona syncOut refusing tarball with an entry listing exceeding the byte limit");
}
pending = Buffer.concat([pending, bytes.subarray(start, end)]);
if (newline < 0) break;
assertTarListingLineConfined(pending.toString("utf8"));
pending = Buffer.alloc(0);
start = newline + 1;
}
}
if (!failure && pending.length > 0) assertTarListingLineConfined(pending.toString("utf8"));
const result = await closed;
if (failure) throw failure;
if (spawnError) throw spawnError;
if (result.code !== 0) {
throw new Error(`Daytona syncOut tar listing failed (${result.signal ?? result.code}): ${stderr.toString("utf8").trim()}`);
}
} catch (error) {
stop(error instanceof Error ? error : new Error(String(error)));
await closed;
throw failure;
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,129 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
vi.mock("./plugin.js", () => ({ getPluginTracer: () => undefined }));
import { assertTarballEntriesConfined } from "./file-sync.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
vi.unstubAllEnvs();
await Promise.all(temporaryDirectories.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
});
async function temporaryDirectory() {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-tar-listing-"));
temporaryDirectories.push(dir);
return dir;
}
// Empty USTAR members are sufficient to exercise the real host tar listing.
// Repeating a member is valid tar and avoids creating 145,000 host files just
// to reproduce the archive metadata buffer limit.
function tarHeader(name: string, prefix = "", target?: string): Buffer {
const header = Buffer.alloc(512);
header.write(name, 0, 100);
header.write("0000644\0", 100);
header.write("0000000\0", 108);
header.write("0000000\0", 116);
header.write("00000000000\0", 124);
header.write("00000000000\0", 136);
header.fill(32, 148, 156);
header.write(target === undefined ? "0" : "2", 156);
if (target !== undefined) header.write(target, 157, 100);
header.write("ustar\0", 257);
header.write("00", 263);
header.write(prefix, 345, 155);
const sum = header.reduce((total, value) => total + value, 0);
header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148);
return header;
}
it.each([false, true])("validates every member beyond 32 MiB of real tar names (unsafe suffix: %s)", async (unsafeSuffix) => {
const archive = path.join(await temporaryDirectory(), "large.tar");
const prefix = "nested-".repeat(21);
const name = "asset-".repeat(15);
const count = 145_000;
expect((prefix.length + 1 + name.length) * count).toBeGreaterThan(32 * 1024 * 1024);
const header = tarHeader(name, prefix);
const batch = Buffer.concat(Array.from({ length: 1_000 }, () => header));
const file = await fs.open(archive, "w");
try {
for (let written = 0; written < count; written += 1_000) await file.write(batch);
if (unsafeSuffix) await file.write(tarHeader("escape", "", "../../outside"));
await file.write(Buffer.alloc(1_024));
} finally {
await file.close();
}
if (unsafeSuffix) {
await expect(assertTarballEntriesConfined(archive)).rejects.toThrow("link whose target escapes");
} else {
await expect(assertTarballEntriesConfined(archive)).resolves.toBeUndefined();
}
}, 30_000);
async function fakeTar(script: string) {
const directory = await temporaryDirectory();
const pidFile = path.join(directory, "pid");
await fs.writeFile(path.join(directory, "tar"), `#!${process.execPath}\n` +
`require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n${script}\n`, { mode: 0o755 });
vi.stubEnv("PATH", `${directory}${path.delimiter}${process.env.PATH}`);
return async () => {
const pid = Number(await fs.readFile(pidFile, "utf8"));
expect(() => process.kill(pid, 0)).toThrow();
};
}
const safeLine = "-rw-r--r-- 0/0 0 2026-09-27 12:00 nested/café.txt";
it("preserves UTF-8 split across chunks and checks a final line without a newline", async () => {
const checkReaped = await fakeTar(`
const bytes = Buffer.from(${JSON.stringify(safeLine)});
const split = bytes.indexOf(Buffer.from("é")) + 1;
process.stdout.write(bytes.subarray(0, split));
setTimeout(() => process.stdout.write(bytes.subarray(split)), 30);
`);
await expect(assertTarballEntriesConfined("unused.tar")).resolves.toBeUndefined();
await checkReaped();
});
it.each([
"not a tar listing",
"-rw-r--r-- 0/0 0 2026-09-27 12:00 ../escape",
"lrwxrwxrwx 0/0 0 2026-09-27 12:00 link -> /outside",
"hrw-r--r-- 0/0 0 2026-09-27 12:00 link link to ../../outside",
"lrwxrwxrwx 0/0 0 2026-09-27 12:00 link -> decoy -> ../../outside",
])("rejects an unsafe final listing and reaps the child: %s", async (line) => {
const checkReaped = await fakeTar(`process.stdout.write(${JSON.stringify(line)});`);
await expect(assertTarballEntriesConfined("unused.tar")).rejects.toThrow("Daytona syncOut refusing");
await checkReaped();
});
it("bounds an unterminated entry instead of buffering it indefinitely", async () => {
const checkReaped = await fakeTar(`process.stdout.write("x".repeat(65_537)); setInterval(() => {}, 1_000);`);
await expect(assertTarballEntriesConfined("unused.tar")).rejects.toThrow("entry listing exceeding the byte limit");
await checkReaped();
});
it("bounds stderr and reaps a noisy tar process", async () => {
const checkReaped = await fakeTar(`process.stderr.write("x".repeat(65_537)); setInterval(() => {}, 1_000);`);
await expect(assertTarballEntriesConfined("unused.tar")).rejects.toThrow("diagnostics exceed the byte limit");
await checkReaped();
});
it("rejects nonzero tar exit even after a valid listing", async () => {
const checkReaped = await fakeTar(`process.stdout.write(${JSON.stringify(safeLine + "\n")}); process.exitCode = 2;`);
await expect(assertTarballEntriesConfined("unused.tar")).rejects.toThrow("tar listing failed (2)");
await checkReaped();
});
it("times out and reaps a stalled listing process", async () => {
const checkReaped = await fakeTar(`setInterval(() => {}, 1_000);`);
await expect(assertTarballEntriesConfined("unused.tar", 200)).rejects.toThrow("timed out");
await checkReaped();
});
it("rejects spawn failures without hanging", async () => {
vi.stubEnv("PATH", await temporaryDirectory());
await expect(assertTarballEntriesConfined("unused.tar")).rejects.toThrow("ENOENT");
});