{"record":{"id":"62419e67829ee8b6","repo":"paperclipai/paperclip","slug":"invalid-canonical-workspace-path","errorCode":null,"errorMessage":"Invalid canonical workspace path","messagePattern":"Invalid canonical workspace path","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"server/src/services/native-runtime/runner-api-files.ts","lineNumber":16,"sourceCode":"import { constants } from \"node:fs\";\nimport { open, type FileHandle } from \"node:fs/promises\";\nimport { isAbsolute } from \"node:path\";\n\n/** Open a previously authorized canonical path without following raced symlinks. */\nexport async function openRunnerApiWorkspaceFile(path: string): Promise<FileHandle> {\n  if (!isAbsolute(path)) throw new Error(\"Workspace file must have a canonical absolute path\");\n  if (process.platform === \"darwin\") {\n    // Darwin sys/fcntl.h: O_NOFOLLOW_ANY rejects symlinks at every component.\n    // Node does not expose this flag in fs.constants. Unsupported kernels fail\n    // closed instead of falling back to a pathname check followed by open.\n    return open(path, constants.O_RDONLY | constants.O_NONBLOCK | 0x20000000);\n  }\n  if (process.platform !== \"linux\") throw new Error(\"Workspace uploads require a platform with confined file opens; use an authorized artifact reference\");\n  const parts = path.split(\"/\").filter(Boolean);\n  if (!parts.length || parts.some(part => part === \".\" || part === \"..\")) throw new Error(\"Invalid canonical workspace path\");\n  let directory = await open(\"/\", constants.O_RDONLY | constants.O_DIRECTORY);\n  try {\n    for (const part of parts.slice(0, -1)) {\n      // Linux magic descriptor links provide openat-style directory confinement.\n      const next = await open(`/proc/self/fd/${directory.fd}/${part}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);\n      await directory.close();\n      directory = next;\n    }\n    return await open(`/proc/self/fd/${directory.fd}/${parts.at(-1)}`, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);\n  } finally { await directory.close(); }\n}\n","sourceCodeStart":1,"sourceCodeEnd":28,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/server/src/services/native-runtime/runner-api-files.ts#L1-L28","documentation":"openRunnerApiWorkspaceFile opens a workspace file via confined, race-free opens (O_NOFOLLOW / procfs fd walk). On Linux it splits the canonical absolute path into segments and rejects any path containing '.' or '..' segments (or an empty path like '/'), because such segments would let the walk escape the workspace root or resolve ambiguously. It is a hard path-traversal guard: the function only accepts fully normalized absolute paths.","triggerScenarios":"Calling openRunnerApiWorkspaceFile with a non-normalized absolute path such as '/workspace/uploads/../secret.txt', '/./file', a path ending in '/..', or the bare root '/'. Any caller-supplied path that has not been run through path.normalize/resolve before reaching the function.","commonSituations":"Joining user-supplied upload filenames with the workspace root without normalization; URL-decoded paths retaining '../'; legacy code building paths with '..' to mean 'parent directory'; a client sending a relative-looking path that was naively prefixed with '/' instead of resolved.","solutions":["Normalize the path before calling: const canonical = path.resolve(workspaceRoot, relative) and verify it startsWith(workspaceRoot + path.sep) before passing it in.","Strip '.' and '..' segments yourself (or reject the request with 400) when the path comes from an external client.","If the intent is to open a parent-directory file, resolve to the concrete absolute child path instead of passing '..' segments.","Only pass paths that were previously authorized/stored as canonical absolute paths, per the function's contract."],"exampleFix":"// before\nconst handle = await openRunnerApiWorkspaceFile(`/workspace/uploads/${name}`);\n\n// after\nconst canonical = path.resolve('/workspace/uploads', name);\nif (!canonical.startsWith('/workspace/uploads/')) throw new Error('outside workspace');\nconst handle = await openRunnerApiWorkspaceFile(canonical);","handlingStrategy":"validation","validationCode":"function isCanonicalWorkspacePath(p: string, root: string): boolean {\n  if (!path.isAbsolute(p)) return false;\n  const rel = path.relative(root, p);\n  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) && !p.split('/').includes('..');\n}\nif (!isCanonicalWorkspacePath(requestedPath, workspaceRoot)) throw new Error('path rejected');","typeGuard":"const isCanonical = (p: string): boolean =>\n  path.isAbsolute(p) && p.split('/').filter(Boolean).every(s => s !== '.' && s !== '..');","tryCatchPattern":null,"preventionTips":["Always build workspace paths with path.resolve from the workspace root, never string concatenation.","Reject (HTTP 400) client-supplied paths containing '.' or '..' segments before they reach the file layer.","Store and reuse the canonical path returned at authorization time instead of reconstructing it.","Add a unit test asserting traversal attempts like '/ws/a/../b' are rejected."],"tags":["path-traversal","filesystem","security","validation"],"backgroundTag":"path-traversal-blocked","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}