From a1a98964592ae23f8fc2d2cc623b8b8b82f0f4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yunus=20Ayd=C4=B1n?= Date: Tue, 5 May 2026 20:18:44 +0300 Subject: [PATCH 1/2] fix(sandbox): refuse symlink/hardlink members in result tarball extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractTarballLocally previously shelled out to `tar -xzvf` with no hardening, so a tarball produced inside the (untrusted) sandbox VM could plant a symlink member followed by a regular file member writing through it. That turned sandbox-side code execution into orchestrator- host arbitrary file write (CVE-2007-4131 / CVE-2018-20482 class) — a clean escape across the trust boundary the README threat model defines. Switch to node-tar via runtime resolution against node_modules/.pnpm (no package.json change). node-tar strips absolute paths and `..` components, refuses to follow symlinks during extraction, and the explicit filter here additionally rejects SymbolicLink and Link members entirely — the agent only ever produces regular FileRecord JSON, so anything else is by definition adversarial. --- packages/deepsec/src/sandbox/download.ts | 102 +++++++++++++++++------ 1 file changed, 76 insertions(+), 26 deletions(-) diff --git a/packages/deepsec/src/sandbox/download.ts b/packages/deepsec/src/sandbox/download.ts index bb599a8..bfe5042 100644 --- a/packages/deepsec/src/sandbox/download.ts +++ b/packages/deepsec/src/sandbox/download.ts @@ -1,6 +1,6 @@ -import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { dataDir } from "@deepsec/core"; import type { Sandbox } from "@vercel/sandbox"; import { DATA_DIR } from "./setup.js"; @@ -115,32 +115,82 @@ export async function downloadResults( return count; } +// The tarball is produced inside the Vercel Sandbox VM, which the README +// threat model explicitly treats as untrusted (prompt-injected agents can +// run shell commands there). A previous `tar -xzvf` shell-out followed +// pre-existing symlinks during extraction, turning sandbox-side code +// execution into orchestrator-host arbitrary file write +// (CVE-2007-4131 / CVE-2018-20482 class). Switch to node-tar, which: +// - strips absolute paths and `..` components by default +// - refuses to extract symlinks pointing outside cwd +// - opens regular files with O_NOFOLLOW under the hood +// The explicit `filter` below additionally rejects symlink AND hardlink +// members entirely — the agent only ever writes regular FileRecord JSON, +// so anything else in the archive is by definition adversarial. async function extractTarballLocally(tarPath: string, destDir: string): Promise { - // Use `tar -xzvf` and count emitted lines for "files extracted" feedback. - return await new Promise((resolve, reject) => { - const child = spawn("tar", ["-xzvf", tarPath, "-C", destDir], { - stdio: ["ignore", "pipe", "pipe"], - }); - let extracted = 0; - let stderr = ""; - let stdoutBuf = ""; - child.stdout.on("data", (c: Buffer) => { - stdoutBuf += c.toString(); - let nl: number; - while ((nl = stdoutBuf.indexOf("\n")) !== -1) { - const line = stdoutBuf.slice(0, nl); - stdoutBuf = stdoutBuf.slice(nl + 1); - if (line && !line.endsWith("/")) extracted++; + const tar = await loadTar(); + let extracted = 0; + await tar.extract({ + file: tarPath, + cwd: destDir, + filter: (_p, entry) => { + const t = (entry as { type?: string }).type; + if (t === "SymbolicLink" || t === "Link") { + throw new Error(`refusing to extract: tarball contains ${t} member`); } - }); - child.stderr.on("data", (c: Buffer) => (stderr += c.toString())); - child.on("error", reject); - child.on("close", (code) => { - if (code !== 0) { - reject(new Error(`tar -xzf exited ${code}: ${stderr.slice(0, 500)}`)); - } else { - resolve(extracted); - } - }); + return true; + }, + onentry: (entry: { type?: string }) => { + if (entry.type === "File") extracted++; + }, }); + return extracted; +} + +// node-tar isn't a direct dep of this package — it's pulled in transitively +// and lives under node_modules/.pnpm/tar@/node_modules/tar. pnpm's +// strict module isolation hides it from a normal `import "tar"`, so resolve +// the install location at runtime by walking up looking for the .pnpm dir. +// Cached after first call. +let cachedTar: TarModule | undefined; + +interface TarExtractOptions { + file: string; + cwd: string; + filter?: (path: string, entry: unknown) => boolean; + onentry?: (entry: { type?: string }) => void; +} + +interface TarModule { + extract: (opts: TarExtractOptions) => Promise; +} + +async function loadTar(): Promise { + if (cachedTar) return cachedTar; + const tarDir = findTarPackageDir(); + const entry = path.join(tarDir, "dist", "esm", "index.min.js"); + const mod = (await import(pathToFileURL(entry).href)) as TarModule; + cachedTar = mod; + return mod; +} + +function findTarPackageDir(): string { + const start = path.dirname(fileURLToPath(import.meta.url)); + let dir = start; + while (true) { + const pnpmDir = path.join(dir, "node_modules", ".pnpm"); + if (fs.existsSync(pnpmDir)) { + const entries = fs.readdirSync(pnpmDir); + const tarEntry = entries.find((e) => /^tar@\d/.test(e)); + if (tarEntry) { + return path.join(pnpmDir, tarEntry, "node_modules", "tar"); + } + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error( + "could not locate node-tar in node_modules/.pnpm — run `pnpm install` from the workspace root", + ); } From a676a55212c21eacdbe37ee5f614d9200ec4c274 Mon Sep 17 00:00:00 2001 From: Yunus AYDIN Date: Tue, 5 May 2026 18:05:15 +0000 Subject: [PATCH 2/2] fix(deepsec): declare tar and load extraction via import() --- packages/deepsec/build.mjs | 1 + packages/deepsec/package.json | 3 +- packages/deepsec/src/sandbox/download.ts | 34 +++------------------ pnpm-lock.yaml | 38 ++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/packages/deepsec/build.mjs b/packages/deepsec/build.mjs index c0eb21a..6e57068 100644 --- a/packages/deepsec/build.mjs +++ b/packages/deepsec/build.mjs @@ -15,6 +15,7 @@ const external = [ "@openai/codex-sdk", "@vercel/sandbox", "jiti", + "tar", ]; const common = { diff --git a/packages/deepsec/package.json b/packages/deepsec/package.json index db2d8cc..ef93c4f 100644 --- a/packages/deepsec/package.json +++ b/packages/deepsec/package.json @@ -36,7 +36,8 @@ "@openai/codex": "^0.125.0", "@openai/codex-sdk": "^0.125.0", "@vercel/sandbox": "^1.9.0", - "jiti": "^2.4.0" + "jiti": "^2.4.0", + "tar": "^7.5.2" }, "devDependencies": { "@deepsec/core": "workspace:*", diff --git a/packages/deepsec/src/sandbox/download.ts b/packages/deepsec/src/sandbox/download.ts index bfe5042..bb54699 100644 --- a/packages/deepsec/src/sandbox/download.ts +++ b/packages/deepsec/src/sandbox/download.ts @@ -1,6 +1,5 @@ import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { dataDir } from "@deepsec/core"; import type { Sandbox } from "@vercel/sandbox"; import { DATA_DIR } from "./setup.js"; @@ -147,11 +146,9 @@ async function extractTarballLocally(tarPath: string, destDir: string): Promise< return extracted; } -// node-tar isn't a direct dep of this package — it's pulled in transitively -// and lives under node_modules/.pnpm/tar@/node_modules/tar. pnpm's -// strict module isolation hides it from a normal `import "tar"`, so resolve -// the install location at runtime by walking up looking for the .pnpm dir. -// Cached after first call. +// node-tar is a declared dependency; keep it external in the bundle so +// `import("tar")` resolves from the installed package at runtime (pnpm, npm, +// or global installs; no reliance on node_modules/.pnpm layout). let cachedTar: TarModule | undefined; interface TarExtractOptions { @@ -167,30 +164,7 @@ interface TarModule { async function loadTar(): Promise { if (cachedTar) return cachedTar; - const tarDir = findTarPackageDir(); - const entry = path.join(tarDir, "dist", "esm", "index.min.js"); - const mod = (await import(pathToFileURL(entry).href)) as TarModule; + const mod = (await import("tar")) as TarModule; cachedTar = mod; return mod; } - -function findTarPackageDir(): string { - const start = path.dirname(fileURLToPath(import.meta.url)); - let dir = start; - while (true) { - const pnpmDir = path.join(dir, "node_modules", ".pnpm"); - if (fs.existsSync(pnpmDir)) { - const entries = fs.readdirSync(pnpmDir); - const tarEntry = entries.find((e) => /^tar@\d/.test(e)); - if (tarEntry) { - return path.join(pnpmDir, tarEntry, "node_modules", "tar"); - } - } - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - throw new Error( - "could not locate node-tar in node_modules/.pnpm — run `pnpm install` from the workspace root", - ); -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7628472..6e36add 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: jiti: specifier: ^2.4.0 version: 2.6.1 + tar: + specifier: ^7.5.2 + version: 7.5.13 devDependencies: '@deepsec/core': specifier: workspace:* @@ -800,6 +803,13 @@ packages: engines: {node: '>=18'} dev: false + /@isaacs/fs-minipass@4.0.1: + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + dependencies: + minipass: 7.1.3 + dev: false + /@jridgewell/sourcemap-codec@1.5.5: resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} dev: true @@ -1721,6 +1731,11 @@ packages: engines: {node: '>= 16'} dev: true + /chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + dev: false + /commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -2291,6 +2306,13 @@ packages: engines: {node: '>=16 || 14 >=14.17'} dev: false + /minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + dependencies: + minipass: 7.1.3 + dev: false + /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2694,6 +2716,17 @@ packages: - react-native-b4a dev: false + /tar@7.5.13: + resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + engines: {node: '>=18'} + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + dev: false + /text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} dependencies: @@ -2982,6 +3015,11 @@ packages: os-paths: 4.4.0 dev: false + /yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + dev: false + /yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'}