nexu-io/open-design · error · Error

path escapes project dir

Error message

path escapes project dir

What it means

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.

Source

Thrown at apps/daemon/src/design/claude-design-import.ts:279

  return validateProjectPath(name);
}

function chooseEntryFile(paths: string[]): string | null {
  const html = paths.filter((p) => /\.html?$/i.test(p));
  if (html.length === 0) return null;
  const lower = new Map(html.map((p) => [p.toLowerCase(), p]));
  return (
    lower.get('index.html') ??
    html.find((p) => !p.includes('/')) ??
    html[0] ??
    null
  );
}

function safeJoin(root: string, relPath: string): string {
  const target = path.resolve(root, relPath);
  if (!target.startsWith(root + path.sep) && target !== root) {
    throw new Error('path escapes project dir');
  }
  return target;
}

View on GitHub (pinned to 5be4028344)

Solutions

  1. Treat this as a security signal — do not import the archive as-is.
  2. Audit the entry names with `unzip -l` for traversal patterns.
  3. Re-create the zip from trusted sources with a flat or cleanly-relative structure.
  4. If the project dir contains symlinks, remove them before importing.
Defensive patterns

Strategy: try-catch

Validate before calling

// Defense-in-depth: ensure the project dir has no symlinks that an entry
// could resolve through to escape.
import { readdirSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
function dirHasSymlinks(dir: string): boolean {
  for (const entry of readdirSync(dir)) {
    if (lstatSync(join(dir, entry)).isSymbolicLink()) return true;
  }
  return false;
}

Try / catch

try {
  await importClaudeDesignZip(zipPath, projectDir);
} catch (err) {
  if (String(err).includes('path escapes project dir')) {
    // Security signal: treat as untrusted archive.
  return res.status(400).json({ error: 'archive refused for safety' });
  }
  throw err;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of nexu-io/open-design@5be4028344 (2026-08-12). Data as JSON: /api/errors/b996bf05a6d8aa16. Report an issue: GitHub.