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

  1. If you own the plugin: replace symlinks inside dist/ui with real copied files in the build step (or bundle the shared assets)
  2. Rebuild the plugin UI bundle so its output directory is self-contained, then reinstall
  3. 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

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

Related errors


AI-assisted analysis of paperclipai/paperclip@120ae5428f (2026-08-18). Data as JSON: /api/errors/b2b0c633be3bb713. Report an issue: GitHub.