{"record":{"id":"b2b0c633be3bb713","repo":"paperclipai/paperclip","slug":"access-denied","errorCode":null,"errorMessage":"Access denied","messagePattern":"Access denied","errorType":"http","errorClass":null,"httpStatus":403,"severity":"warning","filePath":"server/src/routes/plugin-ui-static.ts","lineNumber":450,"sourceCode":"      res.status(404).json({ error: \"File not found\" });\n      return;\n    }\n\n    // Security: resolve symlinks via realpathSync and verify containment.\n    // This prevents symlink-based traversal that string-based startsWith misses.\n    let realFilePath: string;\n    let realUiDir: string;\n    try {\n      realFilePath = fs.realpathSync(resolvedFilePath);\n      realUiDir = fs.realpathSync(uiDir);\n    } catch {\n      res.status(404).json({ error: \"File not found\" });\n      return;\n    }\n\n    const relative = path.relative(realUiDir, realFilePath);\n    if (relative.startsWith(\"..\") || path.isAbsolute(relative)) {\n      res.status(403).json({ error: \"Access denied\" });\n      return;\n    }\n\n    if (!fileStat.isFile()) {\n      res.status(404).json({ error: \"File not found\" });\n      return;\n    }\n\n    // Step 6: Determine cache strategy based on filename\n    const basename = path.basename(resolvedFilePath);\n    const isContentHashed = CONTENT_HASH_PATTERN.test(basename);\n\n    // Step 7: Set cache headers\n    if (isContentHashed) {\n      res.set(\"Cache-Control\", CACHE_CONTROL_IMMUTABLE);\n    } else {\n      res.set(\"Cache-Control\", CACHE_CONTROL_REVALIDATE);\n","sourceCodeStart":432,"sourceCodeEnd":468,"githubUrl":"https://github.com/paperclipai/paperclip/blob/120ae5428fa29bee300bcf806491cd4d965fbb7c/server/src/routes/plugin-ui-static.ts#L432-L468","documentation":"Returned as HTTP 403 by GET /_plugins/:pluginId/ui/* (server/src/routes/plugin-ui-static.ts:450). After realpath-ing both the requested file and the UI directory, path.relative(realUiDir, realFilePath) must not start with '..' or be absolute — i.e. the fully resolved file must stay inside the plugin's UI bundle. This blocks both classic '../' traversal and symlink-based escapes that string startsWith checks miss.","triggerScenarios":"GET /_plugins/<id>/ui/../../secrets.json (encoded or not) resolving outside the bundle; a symlink placed inside dist/ui that points to a directory outside the plugin package (e.g. a pnpm-style symlink to shared node_modules or an author's symlink to ../../shared-assets), whose realpath escapes realUiDir and therefore 403s even for 'legitimate-looking' requests.","commonSituations":"Security scanners probing traversal; plugin authors symlinking shared assets into dist/ui during development and shipping that layout; monorepo tooling that creates cross-package links inside the build output.","solutions":["If you own the plugin: replace symlinks inside dist/ui with real copied files in the build step (or bundle the shared assets)","Rebuild the plugin UI bundle so its output directory is self-contained, then reinstall","If you are the caller: request only paths emitted by the plugin's bundler, relative to the UI root — traversal attempts are rejected by design"],"exampleFix":"# plugin build — before (symlink shared assets)\nln -s ../../shared/assets dist/ui/assets\n\n# after (copy real files into the bundle)\ncp -r ../../shared/assets dist/ui/assets","handlingStrategy":"validation","validationCode":"import path from \"node:path\";\nimport fs from \"node:fs\";\n// Build-time self-check: no real path inside dist/ui may escape it\nconst checkContained = (uiDir: string): string[] => {\n  const realUiDir = fs.realpathSync(uiDir);\n  const walk = (dir: string): string[] =>\n    fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {\n      const p = path.join(dir, e.name);\n      if (e.isDirectory()) return walk(p);\n      const real = fs.realpathSync(p);\n      return path.relative(realUiDir, real).startsWith(\"..\") ? [p] : [];\n    });\n  return walk(uiDir);\n};","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Never place symlinks inside dist/ui that point outside the plugin package — copy or bundle shared assets instead","Add a build step that fails if realpath of any bundled file escapes the UI directory"],"tags":["plugins","path-traversal","symlink","security","http-403","static-files"],"backgroundTag":"path-traversal-blocked","analyzedSha":"120ae5428fa29bee300bcf806491cd4d965fbb7c","analyzedAt":"2026-08-18T22:49:45.177Z","schemaVersion":2},"datasetVersion":"2026-08-22T04:17:13.399Z"}