{"record":{"id":"0dd8a694133b3f78","repo":"garrytan/gstack","slug":"path-must-be-within-safe-directories-join","errorCode":null,"errorMessage":"Path must be within: ${SAFE_DIRECTORIES.join(', ')}","messagePattern":"Path must be within: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"browse/src/path-security.ts","lineNumber":46,"sourceCode":"const TEMP_ONLY = [TEMP_DIR].map(d => {\n  try { return fs.realpathSync(d); } catch { return d; }\n});\n\n/** Validate a file path for writing (screenshot, pdf, download, scrape, archive). */\nexport function validateOutputPath(filePath: string): void {\n  const resolved = path.resolve(filePath);\n\n  // If the target already exists and is a symlink, resolve through it.\n  // Without this, a symlink at /tmp/evil.png → /etc/crontab passes the\n  // parent-directory check (parent is /tmp, which is safe) but the actual\n  // write follows the symlink to /etc/crontab.\n  try {\n    const stat = fs.lstatSync(resolved);\n    if (stat.isSymbolicLink()) {\n      const realTarget = fs.realpathSync(resolved);\n      const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(realTarget, dir));\n      if (!isSafe) {\n        throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);\n      }\n      return; // symlink target verified, no need to check parent\n    }\n  } catch (e: any) {\n    // ENOENT = file doesn't exist yet, fall through to parent-dir check\n    if (e.code !== 'ENOENT') throw e;\n  }\n\n  // For new files (no existing symlink), verify the parent directory.\n  // The file itself may not exist yet (e.g., screenshot output).\n  // This also handles macOS /tmp → /private/tmp transparently.\n  let dir = path.dirname(resolved);\n  let realDir: string;\n  try {\n    realDir = fs.realpathSync(dir);\n  } catch {\n    try {\n      realDir = fs.realpathSync(path.dirname(dir));","sourceCodeStart":28,"sourceCodeEnd":64,"githubUrl":"https://github.com/garrytan/gstack/blob/94993f74012782fd94416dd44b8314f6363a13a4/browse/src/path-security.ts#L28-L64","documentation":"Thrown by validateOutputPath when the target file already exists as a symlink and its realpath resolves OUTSIDE the safe directories (TEMP_DIR or process.cwd()). This is a deliberate guard against the 'symlink inside a safe dir' traversal: without it, /tmp/evil.png → /etc/crontab would pass the parent-directory check (parent is /tmp) but the write would follow the symlink into a system file.","triggerScenarios":"Calling a write command (screenshot, pdf, download, scrape, archive, or eval --out) with a path that is an existing symlink whose target resolves outside TEMP_DIR or cwd. lstatSync reports the link as symbolic, realpathSync resolves it, and isPathWithin fails for every SAFE_DIRECTORIES entry.","commonSituations":"A leftover symlink in /tmp created by another tool or a prior run; a user-created shortcut that happens to point elsewhere; an adversarial test fixture that symlinks into /etc; symlink chain that ultimately escapes the sandbox.","solutions":["Inspect the path: `ls -la <path>` and `readlink -f <path>` to see where it points","Delete or retarget the symlink so its target is inside TEMP_DIR or the project cwd","Pass a fresh filename that is not an existing symlink","If the escape is intentional, write to a path inside the sandbox and copy out afterward"],"exampleFix":"// before: /tmp/shot.png is a symlink → /etc/cron.d/x\nbrowse screenshot /tmp/shot.png  // throws\n\n// after\nrm /tmp/shot.png && browse screenshot /tmp/shot.png","handlingStrategy":"validation","validationCode":"import * as fs from 'fs';\nimport * as path from 'path';\n\nfunction isSafeSymlinkTarget(p: string, safeRoots: string[]): boolean {\n  try {\n    const stat = fs.lstatSync(p);\n    if (!stat.isSymbolicLink()) return true; // not a symlink, let validateOutputPath handle it\n    const real = fs.realpathSync(p);\n    return safeRoots.some(root => real === root || real.startsWith(root + path.sep));\n  } catch (e: any) {\n    if (e.code === 'ENOENT') return true; // doesn't exist yet — no symlink risk\n    throw e;\n  }\n}\n\n// before calling a write command\nif (!isSafeSymlinkTarget(outPath, [require('os').tmpdir(), process.cwd()])) {\n  throw new Error(`refusing to overwrite symlink that escapes sandbox: ${outPath}`);\n}","typeGuard":"function isSymlinkPointingOutside(p: string, safeRoots: string[]): boolean {\n  try {\n    if (!fs.lstatSync(p).isSymbolicLink()) return false;\n    const real = fs.realpathSync(p);\n    return !safeRoots.some(r => real === r || real.startsWith(r + path.sep));\n  } catch (e: any) {\n    if (e.code === 'ENOENT') return false;\n    return true; // treat unresolvable as unsafe\n  }\n}","tryCatchPattern":"try {\n  await runWriteCommand(outPath);\n} catch (e: any) {\n  if (/Path must be within/.test(e.message) && fs.lstatSync(outPath).isSymbolicLink()) {\n    console.error(`Symlink at ${outPath} escapes the sandbox. readlink -f to inspect, then rm and retry.`);\n  }\n  throw e;\n}","preventionTips":["Never write to a path that already exists as a symlink without checking readlink -f first","Use fresh, unique filenames (e.g., append a timestamp or pid) to avoid collisions with leftover symlinks","Periodically clean stray symlinks in TEMP_DIR before runs","Treat any user-supplied output path as untrusted — resolve and bounds-check it before passing to the tool"],"tags":["security","symlink","path-traversal","filesystem","sandbox"],"backgroundTag":null,"analyzedSha":"94993f74012782fd94416dd44b8314f6363a13a4","analyzedAt":"2026-08-12T04:06:23.140Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}