{"record":{"id":"f77c57873a09324a","repo":"santifer/career-ops","slug":"refusing-to-write-the-pdf-outside-the-tracker-workspace","errorCode":null,"errorMessage":"Refusing to write the PDF outside the tracker workspace: ${outputPath}","messagePattern":"Refusing to write the PDF outside the tracker workspace: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"generate-pdf.mjs","lineNumber":1697,"sourceCode":"  const format = opts.format || 'a4';\n  const outputRoot = opts.workspaceRoot || workspaceRoot;\n  const requestedBaseDir = resolve(opts.baseDir || outputRoot);\n  // Temporary HTML is an output too: never let an external input path or\n  // caller-supplied baseDir choose an arbitrary directory. If the requested\n  // directory is outside the tracker workspace (or escapes through a symlink),\n  // keep the render workspace-owned while still allowing the input itself to\n  // be read.\n  const baseDir = isWorkspaceOutputPath(\n    resolve(requestedBaseDir, '.career-ops-render-anchor'),\n    outputRoot,\n  ) ? requestedBaseDir : resolve(outputRoot);\n  const reportNum = opts.reportNum || '';\n  const inputPath = opts.inputPath || '';\n\n  // Reject an escaping destination before creating directories, launching\n  // Chromium, or writing any renderer temporary files (#2844).\n  if (!isWorkspaceOutputPath(outputPath, outputRoot)) {\n    throw new Error(`Refusing to write the PDF outside the tracker workspace: ${outputPath}`);\n  }\n\n  mkdirSync(dirname(outputPath), { recursive: true });\n\n  // Inject the user's theme tokens (config/profile.yml `style:`) as CSS custom\n  // properties so the templates' var(--x, <default>) reads pick them up (#1837).\n  // No `style:` block → no tokens → byte-identical output. Both the CV path and\n  // the cover-letter path flow through here, so both are themed from one place.\n  const styleTokens = opts.styleTokens ?? readStyleTokens();\n  html = injectThemeStyle(html, styleTokens);\n\n  html = injectPrintPageCss(html, format);\n  html = await inlineLocalFonts(html);\n\n  // Write HTML to a temp file in baseDir so page.goto() gives a file://\n  // origin that can load local images, fonts, and other resources.\n  const tmpHtmlPath = resolve(baseDir, `.career-ops-render-${randomUUID()}.html`);\n  const { writeFile, unlink } = await import('fs/promises');","sourceCodeStart":1679,"sourceCodeEnd":1715,"githubUrl":"https://github.com/santifer/career-ops/blob/aac998c7ed7248ea853b720ceeb1fdbeb322fc5d/generate-pdf.mjs#L1679-L1715","documentation":"The single-render PDF path guards its destination before doing any work: if outputPath is not inside the tracker workspace (realpath-based check via isWorkspaceOutputPath), the render is refused before directories are created, Chromium is launched, or temp files are written. This is the single-invocation counterpart of the batch entry guard (#2844).","triggerScenarios":"Calling the render entrypoint (or its opts.outputPath) with a path outside the workspace root: '../x.pdf', an absolute path in another directory, or a symlink that resolves outside the tree.","commonSituations":"Users asking for a CV PDF to be saved to Desktop/Downloads or a shared folder; CI jobs with cwd set outside the repo; symlinked output directories.","solutions":["Pass an outputPath inside the tracker workspace, e.g. output/<name>.pdf.","Render into the workspace, then move the finished PDF to the desired external location.","Check for symlinks: a path that looks inside the tree may resolve outside via a symlinked ancestor."],"exampleFix":"// before\nawait renderPdf({ outputPath: '/home/me/Desktop/cv.pdf' });\n// after\nawait renderPdf({ outputPath: 'output/cv.pdf' });\n// then move it\nmvSync('output/cv.pdf', '/home/me/Desktop/cv.pdf');","handlingStrategy":"validation","validationCode":"import { isAbsolute, resolve } from 'node:path';\nfunction assertSafeOutput(p, root) {\n  const r = resolve(root, p);\n  if (!r.startsWith(resolve(root) + '/')) throw new Error('output must be inside workspace');\n  return r;\n}","typeGuard":null,"tryCatchPattern":"try {\n  await generatePdf(opts);\n} catch (err) {\n  if (err.message.includes('outside the tracker workspace')) {\n    console.error('Render into output/ inside the repo, then move the file externally.');\n  } else throw err;\n}","preventionTips":["Default output paths to output/ inside the repo root.","Render first, move/copy externally second.","Watch for symlinked directories that resolve outside the tree."],"tags":["security","path-traversal","pdf","workspace"],"backgroundTag":"path-traversal-blocked","analyzedSha":"aac998c7ed7248ea853b720ceeb1fdbeb322fc5d","analyzedAt":"2026-09-16T06:35:29.214Z","contentChangedAt":"2026-09-16T06:35:29.214Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}