diff --git a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts index f2350767539..2fa3b40ab2d 100644 --- a/packages/plugins/sandbox-providers/daytona/src/file-sync.ts +++ b/packages/plugins/sandbox-providers/daytona/src/file-sync.ts @@ -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 { - 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 { + // 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); } } diff --git a/packages/plugins/sandbox-providers/daytona/src/tar-listing.test.ts b/packages/plugins/sandbox-providers/daytona/src/tar-listing.test.ts new file mode 100644 index 00000000000..286e3839956 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/tar-listing.test.ts @@ -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"); +});