{"record":{"id":"b52adfbcb36fe355","repo":"garrytan/gstack","slug":"image-resolves-outside-the-input-directory-src","errorCode":null,"errorMessage":"image resolves OUTSIDE the input directory: ${src} → ${realFilePath} — move it under the markdown's directory or drop --strict","messagePattern":"image resolves OUTSIDE the input directory: (.+?) → (.+?) — move it under the markdown's directory or drop --strict","errorType":"validation","errorClass":"StrictModeError","httpStatus":null,"severity":"warning","filePath":"make-pdf/src/diagram-prepass.ts","lineNumber":651,"sourceCode":"    if (!fs.existsSync(filePath)) {\n      const msg = `image not found: ${src} (resolved to ${filePath})`;\n      if (opts.strict) throw new StrictModeError(msg);\n      opts.warn(msg);\n      return buildMissingImagePlaceholder(src);\n    }\n\n    // Out-of-tree reads are legal (local CLI semantics — like pandoc) but\n    // never silent: an agent PDF-ing untrusted markdown should not quietly\n    // embed ~/.ssh/config into a shareable document. --strict makes it fatal.\n    // Compare REAL paths — a symlink inside the input dir pointing outside\n    // would otherwise pass a string-prefix check (Codex adversarial finding).\n    // Runs after the existence check: realpath of a missing file can't\n    // resolve, and on macOS /var vs /private/var would false-positive.\n    const inputRoot = safeRealpath(path.resolve(opts.inputDir)) + path.sep;\n    const realFilePath = safeRealpath(filePath);\n    if (!realFilePath.startsWith(inputRoot)) {\n      const msg = `image resolves OUTSIDE the input directory: ${src} → ${realFilePath}`;\n      if (opts.strict) throw new StrictModeError(msg + \" — move it under the markdown's directory or drop --strict\");\n      opts.warn(msg);\n    }\n\n    // Bound the read BEFORE reading: a markdown image pointing at a special\n    // file (fifo, device) would hang readFileSync, and a multi-GB file would\n    // exhaust memory before any policy ran.\n    let stat: fs.Stats;\n    try {\n      stat = fs.statSync(filePath);\n    } catch {\n      opts.warn(`image unreadable: ${src}`);\n      return buildMissingImagePlaceholder(src);\n    }\n    if (!stat.isFile()) {\n      const msg = `image is not a regular file: ${src}`;\n      if (opts.strict) throw new StrictModeError(msg);\n      opts.warn(msg);\n      return buildMissingImagePlaceholder(src);","sourceCodeStart":633,"sourceCodeEnd":669,"githubUrl":"https://github.com/garrytan/gstack/blob/94993f74012782fd94416dd44b8314f6363a13a4/make-pdf/src/diagram-prepass.ts#L633-L669","documentation":"StrictModeError thrown when a local image's REAL path (safeRealpath) escapes the input directory's real root. The check deliberately uses realpath, not a string prefix, so a symlink inside inputDir pointing outside (e.g. to ~/.ssh/config) is caught — a Codex adversarial finding. Non-strict mode warns; strict makes it fatal. This prevents an agent PDF from silently embedding out-of-tree files.","triggerScenarios":"inlineLocalImages() under --strict where an <img> resolves to a filePath whose safeRealpath does not start with inputRoot+sep. Example: inputDir contains a symlink assets/secret -> /etc/passwd, and markdown references ./assets/secret. The string-prefix check would pass; the realpath check catches it.","commonSituations":"A symlinked asset (intentional or malicious) pointing outside the project; macOS /var vs /private/var normalisation (handled by realpath but a source of confusion); an inputDir that is itself a symlink whose target changes resolution; CI that symlinks a shared assets folder from outside the repo.","solutions":["Move (or copy) the referenced image physically under the markdown's input directory.","Remove or repoint the offending symlink so its realpath stays under inputDir.","Drop --strict to accept the out-of-tree read with a warn (only if you trust the source).","Re-run with the real inputDir (resolve symlinks in the path you pass to make-pdf)."],"exampleFix":"# before: symlink escapes input dir\nln -s /etc/passwd docs/assets/leak.png\nmake-pdf --strict report.md  # throws\n\n# after: keep the asset inside the input tree\ncp /trusted/logos/acme.png docs/assets/acme.png\nmake-pdf --strict report.md","handlingStrategy":"validation","validationCode":"import fs from 'node:fs';\nimport path from 'node:path';\n\nfunction findEscapingImages(markdown: string, inputDir: string): string[] {\n  const root = fs.realpathSync(path.resolve(inputDir)) + path.sep;\n  const re = /!\\[[^\\]]*\\]\\((?!https?:|data:)([^)]+)\\)/g;\n  const bad: string[] = [];\n  let m: RegExpExecArray | null;\n  while ((m = re.exec(markdown))) {\n    const resolved = path.resolve(inputDir, decodeURIComponent(m[1]));\n    if (fs.existsSync(resolved) && !fs.realpathSync(resolved).startsWith(root)) bad.push(m[1]);\n  }\n  return bad;\n}","typeGuard":"import { StrictModeError } from './diagram-prepass';\nfunction isStrictModeError(e: unknown): e is StrictModeError {\n  return e instanceof StrictModeError;\n}","tryCatchPattern":null,"preventionTips":["Never symlink assets outside the input directory; copy them in.","Audit symlinks in the assets folder (`find . -type l -ls`).","Resolve the inputDir through realpath before passing to make-pdf.","Treat strict-mode OUTSIDE errors as security signals, not nuisances."],"tags":["make-pdf","image","security","symlink","strict-mode","path-traversal"],"backgroundTag":null,"analyzedSha":"94993f74012782fd94416dd44b8314f6363a13a4","analyzedAt":"2026-08-12T04:06:23.140Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}