{"record":{"id":"b996bf05a6d8aa16","repo":"nexu-io/open-design","slug":"path-escapes-project-dir","errorCode":null,"errorMessage":"path escapes project dir","messagePattern":"path escapes project dir","errorType":"validation","errorClass":"Error","httpStatus":400,"severity":"error","filePath":"apps/daemon/src/design/claude-design-import.ts","lineNumber":279,"sourceCode":"  return validateProjectPath(name);\n}\n\nfunction chooseEntryFile(paths: string[]): string | null {\n  const html = paths.filter((p) => /\\.html?$/i.test(p));\n  if (html.length === 0) return null;\n  const lower = new Map(html.map((p) => [p.toLowerCase(), p]));\n  return (\n    lower.get('index.html') ??\n    html.find((p) => !p.includes('/')) ??\n    html[0] ??\n    null\n  );\n}\n\nfunction safeJoin(root: string, relPath: string): string {\n  const target = path.resolve(root, relPath);\n  if (!target.startsWith(root + path.sep) && target !== root) {\n    throw new Error('path escapes project dir');\n  }\n  return target;\n}\n","sourceCodeStart":261,"sourceCodeEnd":283,"githubUrl":"https://github.com/nexu-io/open-design/blob/5be4028344c2eb4c667c5a97bda8f750c5597ef7/apps/daemon/src/design/claude-design-import.ts#L261-L283","documentation":"safeJoin resolves path.resolve(root, relPath) and requires the result to start with root + path.sep (or equal root). This catches `..` traversal that resolves outside the project directory — a defense-in-depth guard that runs after validateProjectPath has already normalized the name. Maps to HTTP 400.","triggerScenarios":"An entry name that, after normalization, still resolves outside the project root — e.g. via platform-specific path resolution, a symlink in the project dir, or a regression in validateProjectPath's FORBIDDEN_SEGMENT check.","commonSituations":"Symlink-based escapes inside the project directory; crafted archives from untrusted sources; edge cases in Windows path normalization; a legitimate entry that happens to collide with a reserved segment path.","solutions":["Treat this as a security signal — do not import the archive as-is.","Audit the entry names with `unzip -l` for traversal patterns.","Re-create the zip from trusted sources with a flat or cleanly-relative structure.","If the project dir contains symlinks, remove them before importing."],"exampleFix":null,"handlingStrategy":"try-catch","validationCode":"// Defense-in-depth: ensure the project dir has no symlinks that an entry\n// could resolve through to escape.\nimport { readdirSync, lstatSync } from 'node:fs';\nimport { join } from 'node:path';\nfunction dirHasSymlinks(dir: string): boolean {\n  for (const entry of readdirSync(dir)) {\n    if (lstatSync(join(dir, entry)).isSymbolicLink()) return true;\n  }\n  return false;\n}","typeGuard":null,"tryCatchPattern":"try {\n  await importClaudeDesignZip(zipPath, projectDir);\n} catch (err) {\n  if (String(err).includes('path escapes project dir')) {\n    // Security signal: treat as untrusted archive.\n  return res.status(400).json({ error: 'archive refused for safety' });\n  }\n  throw err;\n}","preventionTips":["Keep the project import directory free of symlinks.","Treat this error as a security signal; do not silently retry.","Re-create archives from trusted sources with flat/relative layouts."],"tags":["zip","security","path-traversal","symlink","import"],"backgroundTag":null,"analyzedSha":"5be4028344c2eb4c667c5a97bda8f750c5597ef7","analyzedAt":"2026-08-12T12:03:58.812Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}