paperclipai/paperclip · warning
Access denied
Error message
Access denied
What it means
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.
Source
Thrown at server/src/routes/plugin-ui-static.ts:450
res.status(404).json({ error: "File not found" });
return;
}
// Security: resolve symlinks via realpathSync and verify containment.
// This prevents symlink-based traversal that string-based startsWith misses.
let realFilePath: string;
let realUiDir: string;
try {
realFilePath = fs.realpathSync(resolvedFilePath);
realUiDir = fs.realpathSync(uiDir);
} catch {
res.status(404).json({ error: "File not found" });
return;
}
const relative = path.relative(realUiDir, realFilePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
res.status(403).json({ error: "Access denied" });
return;
}
if (!fileStat.isFile()) {
res.status(404).json({ error: "File not found" });
return;
}
// Step 6: Determine cache strategy based on filename
const basename = path.basename(resolvedFilePath);
const isContentHashed = CONTENT_HASH_PATTERN.test(basename);
// Step 7: Set cache headers
if (isContentHashed) {
res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE);
} else {
res.set("Cache-Control", CACHE_CONTROL_REVALIDATE);
View on GitHub (pinned to 120ae5428f)
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
Example fix
# plugin build — before (symlink shared assets) ln -s ../../shared/assets dist/ui/assets # after (copy real files into the bundle) cp -r ../../shared/assets dist/ui/assets
Defensive patterns
Strategy: validation
Validate before calling
import path from "node:path";
import fs from "node:fs";
// Build-time self-check: no real path inside dist/ui may escape it
const checkContained = (uiDir: string): string[] => {
const realUiDir = fs.realpathSync(uiDir);
const walk = (dir: string): string[] =>
fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = path.join(dir, e.name);
if (e.isDirectory()) return walk(p);
const real = fs.realpathSync(p);
return path.relative(realUiDir, real).startsWith("..") ? [p] : [];
});
return walk(uiDir);
}; Prevention
- 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
When it happens
Trigger: 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.
Common situations: 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.
Understand the failure class
- Authentication and authorization failures — expired tokens, bad credentials, and missing scopes.
Related errors
- Plugin UI is not available (status: ${plugin.status})
- File not found
- Failed to serve file
- UI parser path escapes package directory — skipping
- sandbox runtime asset key is not a simple path segment: ${ke
AI-assisted analysis of paperclipai/paperclip@120ae5428f (2026-08-18).
Data as JSON: /api/errors/b2b0c633be3bb713.
Report an issue: GitHub.