{"record":{"id":"c3863fa89028b6d8","repo":"garrytan/gstack","slug":"path-must-be-within-safe-directories-join-c3863f","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/write-commands.ts","lineNumber":605,"sourceCode":"      const error = await bm.recreateContext();\n      if (error) {\n        return `User agent set to \"${ua}\" but: ${error}`;\n      }\n      return `User agent set: ${ua}`;\n    }\n\n    case 'upload': {\n      const [selector, ...filePaths] = args;\n      if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2...]');\n\n      // Validate paths are within safe directories (same check as cookie-import)\n      for (const fp of filePaths) {\n        if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);\n        if (path.isAbsolute(fp)) {\n          let resolvedFp: string;\n          try { resolvedFp = fs.realpathSync(path.resolve(fp)); } catch (err: any) { if (err?.code !== 'ENOENT') throw err; resolvedFp = path.resolve(fp); }\n          if (!SAFE_DIRECTORIES.some(dir => isPathWithin(resolvedFp, dir))) {\n            throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);\n          }\n        }\n        if (path.normalize(fp).includes('..')) {\n          throw new Error('Path traversal sequences (..) are not allowed');\n        }\n      }\n\n      const resolved = await session.resolveRef(selector);\n      if ('locator' in resolved) {\n        await resolved.locator.setInputFiles(filePaths);\n      } else {\n        await target.locator(resolved.selector).setInputFiles(filePaths);\n      }\n\n      const fileInfo = filePaths.map(fp => {\n        const stat = fs.statSync(fp);\n        return `${path.basename(fp)} (${stat.size}B)`;\n      }).join(', ');","sourceCodeStart":587,"sourceCodeEnd":623,"githubUrl":"https://github.com/garrytan/gstack/blob/94993f74012782fd94416dd44b8314f6363a13a4/browse/src/write-commands.ts#L587-L623","documentation":"Thrown by `browse upload` when a file path is absolute AND its realpath-resolved location is not inside one of `SAFE_DIRECTORIES` (which is `[TEMP_DIR, process.cwd()]`, each resolved through `realpathSync` to defeat symlink tricks). This is a security guard: the upload command reads local files and pushes them into the browser, so it confines reads to the temp directory and the project working directory to prevent arbitrary file exfiltration from the host.","triggerScenarios":"Passing `/etc/passwd`, `/Users/me/Secrets/key.pem`, or any path outside TEMP_DIR/cwd; passing a path inside a safe dir that is itself a symlink pointing outside (realpathSync follows it and the target fails the check); running the browse server with a cwd different from where the file lives.","commonSituations":"CI runs the browse server with cwd set to the repo root, but the file was staged in `/tmp/staging/` which is neither TEMP_DIR nor cwd; an agent downloaded a file to a custom cache dir outside the two allowed roots; the user expects `~` paths to work and they live under `/home` which is not allowed.","solutions":["Copy or stage the file under TEMP_DIR (`os.tmpdir()`) or the project cwd before uploading.","If the file legitimately lives elsewhere on disk, start the browse server with cwd set to a parent directory that contains it (if that is acceptable for your threat model).","Avoid symlinks that escape the safe directory — resolve them first and confirm the realpath target is inside a safe dir.","Note relative paths skip this branch (only `path.isAbsolute(fp)` enters it) but still hit the `..` traversal check."],"exampleFix":"// before\nawait runBrowseCommand(['upload', 'input[type=file]', '/Users/me/Secrets/key.pem']);\n\n// after\nimport fs from 'fs';\nimport os from 'os';\nimport path from 'path';\nconst staged = path.join(os.tmpdir(), 'key.pem');\nfs.copyFileSync('/Users/me/Secrets/key.pem', staged);\nawait runBrowseCommand(['upload', 'input[type=file]', staged]);","handlingStrategy":"validation","validationCode":"import fs from 'fs';\nimport path from 'path';\nimport os from 'os';\nconst SAFE = [os.tmpdir(), process.cwd()].map(d => { try { return fs.realpathSync(d); } catch { return d; } });\nfunction isPathWithin(p: string, dir: string): boolean {\n  const rel = path.relative(dir, p);\n  return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));\n}\nfunction ensureWithinSafeDirs(fp: string): void {\n  const resolved = path.isAbsolute(fp) ? fs.realpathSync(path.resolve(fp)) : fp;\n  if (path.isAbsolute(fp) && !SAFE.some(d => isPathWithin(resolved, d))) {\n    throw new Error(`Path outside safe dirs: ${fp}`);\n  }\n}","typeGuard":"function isWithinSafeDirs(fp: string): boolean {\n  if (!path.isAbsolute(fp)) return true;\n  let resolved: string;\n  try { resolved = fs.realpathSync(path.resolve(fp)); } catch { resolved = path.resolve(fp); }\n  return SAFE.some(d => isPathWithin(resolved, d));\n}","tryCatchPattern":null,"preventionTips":["Stage files under TEMP_DIR or the project cwd before upload.","Resolve symlinks before checking — realpathSync follows them.","If the file lives elsewhere, copy it into a safe directory first."],"tags":["security","path-validation","filesystem","upload","safe-directories"],"backgroundTag":null,"analyzedSha":"94993f74012782fd94416dd44b8314f6363a13a4","analyzedAt":"2026-08-12T04:06:23.140Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}