{"record":{"id":"d99711e7e82d0f9d","repo":"paperclipai/paperclip","slug":"refusing-to-write-export-file-outside-output-direc","errorCode":null,"errorMessage":"Refusing to write export file outside output directory: ${relativePath}","messagePattern":"Refusing to write export file outside output directory: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"cli/src/commands/client/company.ts","lineNumber":1230,"sourceCode":"  for (const [relativePath, content] of Object.entries(exported.files)) {\n    const normalized = relativePath.replace(/\\\\/g, \"/\");\n    const filePath = resolveExportOutputPath(root, normalized);\n    await mkdir(path.dirname(filePath), { recursive: true });\n    const writeValue = portableFileEntryToWriteValue(content);\n    if (typeof writeValue === \"string\") {\n      await writeFile(filePath, writeValue, \"utf8\");\n    } else {\n      await writeFile(filePath, writeValue);\n    }\n  }\n}\n\nexport function resolveExportOutputPath(root: string, relativePath: string): string {\n  const resolvedRoot = path.resolve(root);\n  const filePath = path.resolve(resolvedRoot, relativePath);\n  const rootPrefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;\n  if (filePath !== resolvedRoot && !filePath.startsWith(rootPrefix)) {\n    throw new Error(`Refusing to write export file outside output directory: ${relativePath}`);\n  }\n  return filePath;\n}\n\nexport async function confirmOverwriteExportDirectory(\n  outDir: string,\n  opts: { force?: boolean } = {},\n): Promise<void> {\n  const root = path.resolve(outDir);\n  const stats = await stat(root).catch(() => null);\n  if (!stats) return;\n  if (!stats.isDirectory()) {\n    throw new Error(`Export output path ${root} exists and is not a directory.`);\n  }\n\n  const entries = await readdir(root);\n  if (entries.length === 0) return;\n","sourceCodeStart":1212,"sourceCodeEnd":1248,"githubUrl":"https://github.com/paperclipai/paperclip/blob/67001ec6eb96ae601aa27bc91d9b2415d665334a/cli/src/commands/client/company.ts#L1212-L1248","documentation":"Thrown by resolveExportOutputPath when a relative export entry, after path.resolve against the output root, escapes the root directory. This is a path-traversal guard: zip entries or portable file entries whose relativePath uses '../' or an absolute path would otherwise write anywhere on disk.","triggerScenarios":"A malformed/tampered export payload containing entries like '../../etc/passwd', '/etc/foo', or absolute Windows paths. A portable export produced by a buggy exporter that recorded absolute paths instead of relative ones. Symlink edge cases where path.resolve leaves the target outside the root.","commonSituations":"Importing a company package sourced from an untrusted third party. A bug in the export format that stored root-relative paths with a leading slash. Cross-platform path separators (backslash) confusing the prefix check on a non-Windows host.","solutions":["Sanitize each entry's relativePath before writing: strip leading slashes and any '..' segments.","Regenerate the export from a trusted source so entries are properly root-relative.","If you control the payload, ensure portable entries never contain '..' or absolute paths.","Audit the export file with 'unzip -l' (or equivalent) and reject packages containing traversal paths."],"exampleFix":"// before (malicious/buggy entry)\nresolveExportOutputPath(\"/out\", \"../../etc/crontab\");\n// after (guard at the source of the payload)\nconst safe = relativePath.replace(/\\\\/g, \"/\").replace(/^(\\.{1,2}\\/)+/, \"\");\nresolveExportOutputPath(\"/out\", safe);","handlingStrategy":"validation","validationCode":"function sanitizeRelativePath(p: string): string {\n  let s = p.replace(/\\\\/g, \"/\").replace(/^\\/+/, \"\");\n  const segments = s.split(\"/\").filter((seg) => seg && seg !== \".\" && !(seg === \"..\"));\n  return segments.join(\"/\");\n}\n// apply to every entry before resolveExportOutputPath","typeGuard":"function isSafeRelativePath(root: string, p: string): boolean {\n  const path = require(\"path\");\n  const resolvedRoot = path.resolve(root);\n  const filePath = path.resolve(resolvedRoot, p);\n  const prefix = resolvedRoot.endsWith(path.sep) ? resolvedRoot : resolvedRoot + path.sep;\n  return filePath === resolvedRoot || filePath.startsWith(prefix);\n}","tryCatchPattern":null,"preventionTips":["Never trust relative paths from untrusted packages — sanitize first.","Reject export entries containing '..' or leading slashes at ingestion time.","Run exports into a dedicated empty directory to limit blast radius.","Audit imported zip manifests before writing."],"tags":["cli","security","path-traversal","company-export","validation"],"backgroundTag":null,"analyzedSha":"67001ec6eb96ae601aa27bc91d9b2415d665334a","analyzedAt":"2026-08-12T12:05:45.408Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}