← Blog

Claude skill path traversal security

File-access MCP skills accept path arguments and operate on files within an intended directory. Path traversal vulnerabilities allow an attacker to escape that directory by supplying a path that resolves outside the intended boundary — reading /etc/passwd, ~/.ssh/id_rsa, or any other file accessible to the MCP process. The four patterns in SA-SKL-STR-013 through STR-016 cover the containment failures that appear in Node.js file-access skills: using path.join() without resolving the final path to an absolute canonical form before checking, relying on path.normalize() alone (which does not prevent traversal via Windows-style backslash sequences on cross-platform code), bypasses via URL-percent-encoded traversal sequences, and symlinks that point outside the allowed directory and are followed transparently by fs.readFile().

By SkillAudit  |  3 Nov 2026

Path traversal in MCP skills is particularly dangerous because the MCP host process typically runs with the same filesystem permissions as the user — or as a system service with broad access to configuration files, secrets, and application data. An LLM that receives a path from external content (a document, an email, a web page via indirect prompt injection) can pass that path verbatim to a file-access tool, making path traversal a post-injection second-stage attack: injection delivers the traversal payload, the skill executes it. The containment check — verifying that the resolved canonical path starts with the allowed base directory — is the critical control, and it must come after all path transformations, not before.

SA-SKL-STR-013: path.join without path.realpath and startsWith boundary check

path.join(base, userInput) concatenates path segments and normalises separators — but it does not resolve .. sequences against the filesystem. The result is a syntactically normalised path that may still resolve outside the base directory: path.join('/safe/dir', '../../../etc/passwd') returns /etc/passwd after normalization, not a path within /safe/dir. The correct containment check requires two steps: first, resolve the joined path to its absolute canonical form with path.realpath() (which also resolves symlinks — see STR-016), then check that the canonical path starts with the allowed base directory. The check must run on the resolved path, not the joined-but-unresolved path.

import path from 'path';
import fs from 'fs/promises';

const ALLOWED_DIR = '/app/data/user-files';

// VULNERABLE: path.join without realpath — ../sequences escape the directory
server.setToolHandler('read_user_file', async (args) => {
  // path.join normalises separators but does NOT resolve ../ against filesystem
  const filePath = path.join(ALLOWED_DIR, args.filename);
  // args.filename = '../../../../etc/passwd' → filePath = '/etc/passwd'
  // path.join collapses the ../ sequences and produces a path outside ALLOWED_DIR

  // This startsWith check runs on the path.join output — which is already outside:
  // '/etc/passwd'.startsWith('/app/data/user-files') === false
  // But wait — the developer forgot the check entirely:
  const content = await fs.readFile(filePath, 'utf8');  // ← /etc/passwd is read
  return { content };
});

// ALSO VULNERABLE: startsWith check on path.join result (without realpath)
server.setToolHandler('read_user_file_v2', async (args) => {
  const filePath = path.join(ALLOWED_DIR, args.filename);

  // Check is on join result — but ../sequences in args.filename already escaped:
  // path.join('/safe', '../../../etc/passwd') = '/etc/passwd'
  // '/etc/passwd'.startsWith('/app/data/user-files') → false → correctly rejects
  // BUT: path.join('/safe/dir', './subdir/../../../etc/passwd') may produce results
  // where manual normalisation and startsWith disagree on OS edge cases
  if (!filePath.startsWith(ALLOWED_DIR + '/')) {
    throw new Error('Access denied');
  }
  // Note: still vulnerable if args.filename is URL-encoded (see STR-015)
  // or points to a symlink (see STR-016)
  const content = await fs.readFile(filePath, 'utf8');
  return { content };
});

// SECURE: path.realpath resolves canonical absolute path — then check boundary
server.setToolHandler('read_user_file', async (args) => {
  // Step 1: join base with user-supplied path segment
  const joined = path.join(ALLOWED_DIR, args.filename);

  // Step 2: resolve to canonical absolute path — resolves symlinks and ../ sequences
  // realpath throws if the file does not exist — catch and treat as access denied
  let canonical: string;
  try {
    canonical = await fs.realpath(joined);
  } catch {
    throw new Error('File not found');
  }

  // Step 3: boundary check on the canonical path (after all resolution)
  // Trailing '/' in ALLOWED_DIR prevents /app/data/user-filesmalicious matching
  const allowedBase = ALLOWED_DIR.endsWith('/') ? ALLOWED_DIR : ALLOWED_DIR + '/';
  if (!canonical.startsWith(allowedBase) && canonical !== ALLOWED_DIR) {
    throw new Error('Access denied: path outside allowed directory');
  }

  // Step 4: read using the canonical path — not the original joined path
  const content = await fs.readFile(canonical, 'utf8');
  return { content };
});

// DETECTION (SkillAudit):
// fs.readFile(path.join(ALLOWED_DIR, args.*)) without path.realpath() call before boundary check
// OR fs.readFile(joinedPath) where joinedPath has no startsWith(allowedBase) guard at all
const noRealpath = searchAST(
  'fs.readFile(path.join(*, args.*)) without path.realpath() + startsWith() boundary check'
);
if (noRealpath.length > 0) → CRITICAL  // SA-SKL-STR-013

The boundary check must use the realpath result, not the join result: path.join(base, userInput).startsWith(base) is not safe if userInput contains ../ sequences, encoded characters, or null bytes. The correct sequence is: (1) path.join(base, userInput), (2) path.realpath(joined), (3) canonical.startsWith(base + '/'). If step 2 throws (file does not exist), deny access. If step 3 fails (canonical path outside base), deny access. Steps cannot be reordered and step 2 cannot be skipped.

CRITICAL
SA-SKL-STR-013 — path.join without realpath boundary check: path.join(base, args.filename) without path.realpath() before startsWith() check — ../ sequences in user input escape the allowed directory. Detection: fs.readFile(path.join(base, args.*)) without path.realpath() → CRITICAL.

SA-SKL-STR-014: path.normalize does not prevent traversal via Windows backslash sequences

path.normalize() resolves ./, ../, and redundant separators in a path. On POSIX systems, it uses / as the separator. On Windows, it also handles \ as a separator. When a Node.js MCP skill runs on Windows (or in cross-platform code that processes Windows-style paths), an attacker can supply backslash-based traversal sequences: ..\..\..\windows\win.ini. If the skill calls path.normalize() on POSIX (which does not treat \ as a separator), the backslashes are not resolved — path.normalize('../\\..\\..\\etc/passwd') on Linux returns '../\\..\\..\\etc/passwd' unchanged. A subsequent startsWith() check may incorrectly believe the path is safe because it does not start with ../. The cross-platform guarantee only holds if path.realpath() is used, which resolves the OS-specific canonical path.

import path from 'path';
import fs from 'fs/promises';

const ALLOWED_DIR = 'C:\\Users\\app\\data';  // Windows deployment

// VULNERABLE: path.normalize alone — does not prevent backslash traversal on Windows
server.setToolHandler('read_config', async (args) => {
  const filePath = path.normalize(path.join(ALLOWED_DIR, args.configFile));
  // On Windows: args.configFile = '..\\..\\..\windows\win.ini'
  // path.join handles backslash on Windows → produces 'C:\Windows\win.ini'
  // A POSIX startsWith check on this would fail (correctly), but developers
  // writing cross-platform code may not test the Windows-specific backslash handling

  // ADDITIONAL PROBLEM: path.normalize on Linux with Windows-style input
  // path.normalize('/safe/dir/..\\..\\/etc/passwd') = '/safe/dir/..\\..\\/etc/passwd'
  // (backslash not treated as separator on Linux — normalize doesn't collapse it)
  // But fs.readFile may resolve backslash differently than path.normalize

  if (!filePath.startsWith(ALLOWED_DIR)) {
    throw new Error('Access denied');
  }
  return { content: await fs.readFile(filePath, 'utf8') };
});

// ALSO VULNERABLE: URL-segment path construction with backslash
// URL paths may contain backslash-encoded sequences that decode to traversal
function buildFilePath(urlPathSegment: string): string {
  // decodeURIComponent may produce backslashes: '%5C' → '\'
  const decoded = decodeURIComponent(urlPathSegment);
  return path.join(ALLOWED_DIR, decoded);  // backslash in decoded may traverse on Windows
}

// SECURE: platform-agnostic containment via path.realpath (resolves all separator styles)
server.setToolHandler('read_config', async (args) => {
  // Reject backslash at input validation time — before any path operations
  if (args.configFile.includes('\\')) {
    throw new Error('Invalid path: backslash not allowed');
  }

  const joined = path.join(ALLOWED_DIR, args.configFile);

  let canonical: string;
  try {
    canonical = await fs.realpath(joined);
  } catch {
    throw new Error('File not found');
  }

  // Ensure canonical path is within allowed directory
  const allowedBase = path.resolve(ALLOWED_DIR);
  if (!canonical.startsWith(allowedBase + path.sep) && canonical !== allowedBase) {
    throw new Error('Access denied: path traversal detected');
  }

  return { content: await fs.readFile(canonical, 'utf8') };
});

// Reject suspicious path inputs at the schema validation level
import { z } from 'zod';

const PathSchema = z.string()
  .refine(p => !p.includes('..'), 'Path traversal sequences not allowed')
  .refine(p => !p.includes('\\'), 'Backslash not allowed in path')
  .refine(p => !p.includes('\0'), 'Null byte not allowed in path')
  .refine(p => !p.startsWith('/'), 'Absolute paths not allowed')
  .max(512, 'Path too long');

// DETECTION (SkillAudit):
// path.normalize() used without path.realpath() + startsWith() check
// OR path construction without backslash and null byte input rejection
const normalizeOnly = searchAST(
  'path.normalize(path.join(*, args.*)) without path.realpath() boundary check'
);
if (normalizeOnly.length > 0) → HIGH  // SA-SKL-STR-014

Cross-platform path handling is a consistency problem: Node.js’s path module has separate path.posix and path.win32 implementations. path.normalize() uses the platform’s native implementation, which means its behavior differs between Linux and Windows deployments. Skills that run on both platforms must use path.realpath() (which always asks the OS to resolve the canonical path, regardless of which separator style was used) and must reject null bytes and backslashes at input validation before any path construction.

HIGH
SA-SKL-STR-014 — path.normalize backslash bypass: path.normalize() does not resolve backslash traversal sequences on Linux; %5C-decoded backslashes in user input can traverse on Windows deployments. Detection: path.normalize() without path.realpath() + backslash input rejection → HIGH.

SA-SKL-STR-015: URL.pathname decoded form bypasses string-matching with percent-encoded traversal

MCP skills that construct filesystem paths from URL paths or URL path segments must handle percent-encoding. A traversal sequence of ../ can be encoded as %2e%2e%2f, %2e%2e/, ..%2f, or %2e%2e%5c (Windows). A string-matching check that looks for .. in the raw URL fails to detect encoded variants. Even decodeURIComponent() followed by a .. check can be bypassed with double-encoding: %252e%252e%252f decodes to %2e%2e%2f after one decodeURIComponent() call, and to ../ after a second decode — but if the filesystem path resolution performs an additional decode, the traversal executes after the check has already passed. The safe pattern is to construct the path using the OS-level path.realpath() after joining, which decodes and resolves through the OS path resolution API, then check the canonical result against the boundary.

import { URL } from 'url';
import path from 'path';
import fs from 'fs/promises';

const ALLOWED_DIR = '/app/user-data';

// VULNERABLE: string-match on URL.pathname without full decoding
server.setToolHandler('serve_file', async (args) => {
  const url = new URL(args.fileUrl);
  const urlPath = url.pathname;

  // String check on the URL pathname — does not catch encoded traversal
  // urlPath might be: '/%2e%2e/%2e%2e/%2e%2e/etc/passwd'
  // string check: urlPath.includes('..') → false (no literal ../ in raw URL)
  if (urlPath.includes('..')) {
    throw new Error('Traversal attempt detected');  // bypassed by encoding
  }

  const filePath = path.join(ALLOWED_DIR, urlPath);
  return { content: await fs.readFile(filePath, 'utf8') };  // ← reads /etc/passwd
});

// ALSO VULNERABLE: single decodeURIComponent with double-encoding bypass
server.setToolHandler('serve_file_v2', async (args) => {
  const urlPath = decodeURIComponent(args.fileUrl);
  // Single decode: '%252e%252e%252f' → '%2e%2e%2f' (still encoded, check passes)
  if (urlPath.includes('..')) {
    throw new Error('Traversal attempt');  // bypassed — still encoded after one decode
  }
  // But: some web frameworks or HTTP servers perform a second decode on the path
  // so the filesystem receives the fully decoded '../' after the check already passed
  const filePath = path.join(ALLOWED_DIR, urlPath);
  return { content: await fs.readFile(filePath, 'utf8') };
});

// SECURE: full canonical resolution via path.realpath (OS decodes all encodings)
server.setToolHandler('serve_file', async (args) => {
  // Input validation: reject suspicious patterns before any path construction
  const rawPath = args.filePath;  // use file path directly, not URL parsing
  if (/\.\./.test(rawPath)) throw new Error('Traversal sequences not allowed');
  if (/[%\0\\]/.test(rawPath)) throw new Error('Invalid characters in path');

  const joined = path.join(ALLOWED_DIR, rawPath);

  let canonical: string;
  try {
    canonical = await fs.realpath(joined);
  } catch {
    throw new Error('File not found');  // does not distinguish not-found from traversal
  }

  const allowedBase = path.resolve(ALLOWED_DIR);
  if (!canonical.startsWith(allowedBase + '/') && canonical !== allowedBase) {
    throw new Error('Access denied');
  }

  return { content: await fs.readFile(canonical, 'utf8') };
});

// When URLs must be parsed: decode BEFORE the ../ check, then re-encode for path.join
function safePathFromUrl(urlPathSegment: string, baseDir: string): string {
  // Fully decode the URL segment — normalises all percent-encoding variants
  let decoded: string;
  try {
    // Decode once; if still encoded, decode again (handles double-encoding)
    decoded = decodeURIComponent(decodeURIComponent(urlPathSegment));
  } catch {
    throw new Error('Invalid URL encoding');
  }

  // After full decode, check for traversal sequences
  if (decoded.includes('..') || decoded.includes('\0') || decoded.includes('\\')) {
    throw new Error('Invalid path');
  }

  return path.join(baseDir, decoded);
}

// DETECTION (SkillAudit):
// url.pathname | req.url used in path construction without full URL decoding
// before string-level traversal check
const urlEncodedTraversal = searchAST(
  'path.join(baseDir, url.pathname | req.url) without decodeURIComponent + .. check'
);
if (urlEncodedTraversal.length > 0) → HIGH  // SA-SKL-STR-015

URL percent-encoding and filesystem path resolution are different transformations: a string check for ../ on a raw URL path is checking the percent-encoded form, not the filesystem-resolved form. The OS filesystem resolver decodes percent-encoding and resolves traversal sequences simultaneously — what arrives as %2e%2e%2f at the string level becomes ../ at the filesystem level. The only check that is authoritative about what the filesystem will do is asking the filesystem: call path.realpath(), let the OS decode and resolve, then check the canonical result against the boundary.

HIGH
SA-SKL-STR-015 — URL-encoded path traversal: url.pathname or percent-encoded path segments used in path.join() without full decode before string-level .. check — %2e%2e%2f and double-encoded variants bypass literal traversal detection. Detection: path.join(base, url.pathname) without decodeURIComponent + traversal check → HIGH.

SA-SKL-STR-016: symlink following escapes intended directory bounds after path.realpath check

Symlinks (symbolic links) redirect filesystem access: a symlink at /app/user-files/secret → /etc/passwd causes fs.readFile('/app/user-files/secret') to read /etc/passwd. path.realpath() resolves symlinks to their canonical target — so a path that passes the realpath().startsWith(allowed) check today may later fail if a symlink inside the allowed directory is modified to point outside. More specifically: if an attacker can create a file inside the allowed directory (e.g. via a write tool in the same skill), they can create a symlink there that points to any file on the filesystem. When a subsequent read of that symlink’s name is attempted, path.realpath() resolves the symlink to its target — which is outside the allowed directory — and the startsWith check correctly fails. But if the check is cached or if the skill uses fs.readFile() directly without rechecking after a write operation creates a symlink, the traversal may succeed.

import path from 'path';
import fs from 'fs/promises';

const ALLOWED_DIR = '/app/user-data';

// VULNERABLE: symlink created inside allowed dir can bypass realpath+startsWith
// if the skill both writes files/symlinks AND reads files

// Step 1: Attacker calls write_file with a symlink path
// (Some skills accept file content and create the file using fs.writeFile)
// Step 2: Attacker calls read_file with the same filename

// SCENARIO: skill has both write and read tools
server.setToolHandler('write_file', async (args) => {
  const filePath = await safeResolvePath(ALLOWED_DIR, args.filename);
  await fs.writeFile(filePath, args.content);  // creates a file
  // If attacker can somehow make this write a symlink instead of a regular file...
});

// The core symlink risk: path.realpath resolves AT CHECK TIME, not at read time
// Race window: between realpath check and fs.readFile, a symlink could be created
server.setToolHandler('read_file', async (args) => {
  const joined = path.join(ALLOWED_DIR, args.filename);
  const canonical = await fs.realpath(joined);  // check at time T1

  if (!canonical.startsWith(ALLOWED_DIR + '/')) {
    throw new Error('Access denied');
  }

  // If a symlink is created between T1 and T2 (unlikely but possible in concurrent access):
  const content = await fs.readFile(joined, 'utf8');  // reads at time T2 via original path
  // ← should read via canonical (resolved) path, not joined (pre-resolution) path
  return { content };
});

// SECURE: read via canonical path AND use O_NOFOLLOW for final open (Linux)
server.setToolHandler('read_file', async (args) => {
  const joined = path.join(ALLOWED_DIR, args.filename);

  let canonical: string;
  try {
    canonical = await fs.realpath(joined);
  } catch {
    throw new Error('File not found');
  }

  if (!canonical.startsWith(ALLOWED_DIR + '/') && canonical !== ALLOWED_DIR) {
    throw new Error('Access denied: path outside allowed directory');
  }

  // Read via canonical path (symlinks already resolved) — not via the joined path
  const content = await fs.readFile(canonical, 'utf8');
  return { content };
});

// ADDITIONAL DEFENCE: check that the resolved file is a regular file, not a symlink/dir
server.setToolHandler('read_file_strict', async (args) => {
  const joined = path.join(ALLOWED_DIR, args.filename);

  let canonical: string;
  let stat: fs.Stats;
  try {
    canonical = await fs.realpath(joined);
    stat = await fs.stat(canonical);  // stat follows symlinks — use lstat for symlink detection
  } catch {
    throw new Error('File not found');
  }

  const allowedBase = path.resolve(ALLOWED_DIR);
  if (!canonical.startsWith(allowedBase + '/') && canonical !== allowedBase) {
    throw new Error('Access denied');
  }

  if (!stat.isFile()) {
    throw new Error('Not a regular file');  // rejects directories and (already-resolved) symlinks
  }

  return { content: await fs.readFile(canonical, 'utf8') };
});

// DETECTION (SkillAudit):
// fs.readFile(path.join(base, args.*)) where reading occurs via original joined path
// after realpath check (race window), OR skill has both write and read tools
// without symlink detection in the write path
const symlinkRisk = searchAST(
  'fs.realpath(joined) then startsWith check then fs.readFile(joined) ' +
  '(reading via original path, not canonical) ' +
  'OR skill registers both write_file and read_file handlers without lstat symlink check'
);
if (symlinkRisk.length > 0) → HIGH  // SA-SKL-STR-016

Use lstat() to detect symlinks before following: fs.stat() follows symlinks and reports the target file’s properties. fs.lstat() reports the symlink itself — so if lstat().isSymbolicLink() returns true, the path is a symlink that has not yet been followed. In read-only file-access skills, detecting and rejecting symlinks at the input path (before calling realpath()) eliminates the symlink attack surface entirely. In skills that legitimately support symlinks, ensure the symlink target is within the allowed directory by checking the realpath() result and reading via the canonical path.

HIGH
SA-SKL-STR-016 — Symlink escape: symlink inside allowed directory resolves to a target outside allowed directory — path.realpath() returns out-of-bounds target, which fails the startsWith check, but reading via the original path (not canonical) or a race between check and read can follow the symlink. Detection: fs.readFile(joined) after realpath check instead of fs.readFile(canonical) → HIGH.

Remediation summary

FindingSeverityFix
STR-013: path.join without realpath boundary checkCRITICALAlways call path.realpath() after path.join() and before startsWith(); deny if realpath throws; read via canonical path not original joined path
STR-014: path.normalize backslash bypassHIGHReject backslash in path input at schema validation; use path.realpath() not path.normalize() as the containment mechanism
STR-015: URL-encoded traversal bypassHIGHDouble-decode URL path segments before string-level traversal check; reject % and null bytes in file path inputs; rely on path.realpath() as final authority
STR-016: Symlink escape after realpathHIGHRead via canonical path (not original joined path); use lstat() to detect and reject symlinks in read-only skills; check realpath target is within allowed base

SkillAudit detects SA-SKL-STR-013 through STR-016 through AST-based analysis of path construction patterns, realpath usage, and boundary check placement in file-access tool handlers. This page continues the SA-SKL-STR family started in SA-SKL-STR schema validation bypass, SA-SKL-STR input sanitization bypass, and SA-SKL-STR output encoding security. See the complete SA-SKL attack surface reference for the full taxonomy.

Scan your Claude skill for path traversal vulnerabilities

SkillAudit detects path.join without realpath, backslash traversal bypass, URL-encoded sequences, and symlink escape across the full SA-SKL-STR family in 60 seconds.

Get your free audit