Security reference · Path Traversal · File System

MCP server path normalization security

Path containment — verifying that a user-supplied file path resolves within a permitted root directory — is the standard mitigation for path traversal in MCP file tool handlers. But naive containment checks using startsWith(allowedRoot) on the raw input string fail against a catalog of normalization bypass techniques: Unicode look-alike slash characters, null byte injection, Windows UNC path formats on Linux systems, and symlink chains that exit the allowed root. This reference walks through each bypass, explains why it succeeds, and gives the six-step safe validation sequence that closes all of them.

Bypass 1 — Unicode full-width and fraction slash

Unicode contains several code points that visually resemble the forward slash / but are distinct characters. When passed through startsWith() before normalization, they do not match the slash in the allowed root path. But Node.js's filesystem layer normalizes Unicode paths before calling into the OS, so the OS sees the slash and the path traversal succeeds.

Character Unicode code point Name After NFKC normalization
U+FF0F Full-width solidus /
U+2215 Division slash /
U+2044 Fraction slash /
U+2571 Box drawings light diagonal Does NOT normalize (safe in NFKC)
// VULNERABLE: startsWith() check without Unicode normalization
function isPathSafe_WRONG(input: string, root: string): boolean {
  return input.startsWith(root);  // Fails for U+FF0F and U+2215
}

// Attack: attacker sends '/etc/passwd' (U+FF0F, not U+002F)
// isPathSafe_WRONG('/safe/root', '/etc/passwd') → false (doesn't start with /safe/root)
// But Node.js passes '/etc/passwd' to the OS, which sees '/etc/passwd'
const attackPath = '/etc/passwd';
console.log(attackPath.startsWith('/etc'));   // false
// fs.readFileSync(attackPath) reads /etc/passwd on Linux

Bypass 2 — Null byte injection

In C and older POSIX APIs, strings are null-terminated. Inserting a null byte (\x00) in a path causes many low-level functions to stop reading at that point. Some higher-level libraries (and older versions of Node.js) passed the string to the OS with the null byte intact, truncating the path at the null byte. Modern Node.js rejects paths with null bytes in fs calls, but some third-party path utilities may not:

// Modern Node.js (v18+): throws ENOENT or ERR_INVALID_ARG_VALUE on null bytes in fs calls
// But always explicitly reject null bytes BEFORE calling any path utility
function assertNoNullBytes(input: string): void {
  if (input.includes('\x00') || input.includes('')) {
    throw new Error('null_byte_in_path');
  }
}

// Also reject URL-encoded null bytes (%00) if the input came from URL parsing
function assertNoEncodedNullBytes(input: string): void {
  if (/%00/i.test(input)) {
    throw new Error('encoded_null_byte_in_path');
  }
}

Bypass 3 — Windows UNC paths on Linux

Windows UNC paths (\\server\share\file) use backslashes and a \\ prefix for network paths. On Linux, Node.js's path.resolve() and path.normalize() handle backslash-separated paths differently depending on the Node.js version and platform. On Windows systems, UNC paths can reference arbitrary network shares. Even on Linux, some path-manipulating libraries incorrectly handle inputs with backslashes:

// Check for Windows path patterns (relevant even on Linux if input is user-controlled)
function assertNoWindowsPaths(input: string): void {
  // UNC path: starts with \\ or //
  if (/^[\\\/]{2}/.test(input)) {
    throw new Error('unc_path_rejected');
  }
  // Windows drive letter: C:\ or C:/
  if (/^[a-zA-Z]:[\\\/]/.test(input)) {
    throw new Error('windows_drive_path_rejected');
  }
}

// Normalize backslashes to forward slashes (Windows-style paths on any platform)
function normalizeSlashes(input: string): string {
  return input.replace(/\\/g, '/');
}

Platform-specific behavior: If your MCP server can run on both Linux and Windows (common for desktop Claude Code extensions), path normalization behavior differs significantly between platforms. Test path validation on all target platforms, or restrict path handling to POSIX-style paths by rejecting any input with backslashes.

Bypass 4 — Symlink escape

A symlink inside the allowed root directory can point to a location outside it. A path like /safe/root/link/../../../etc/passwd resolves to /etc/passwd, but even /safe/root/link/target.txt can reach outside if link is a symlink pointing to /etc/. path.resolve() does not follow symlinks — it operates on the lexical path. fs.realpath() follows symlinks and returns the true resolved path.

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

// path.resolve() resolves ../ sequences lexically but does NOT follow symlinks
// fs.realpath() follows symlinks and returns the actual filesystem path
async function assertPathContained(input: string, allowedRoot: string): Promise<string> {
  // Step 1: null byte check
  if (input.includes('\x00')) throw new Error('null_byte_rejected');
  // Step 2: Windows path check
  if (/^[a-zA-Z]:/.test(input) || /^\\\\/.test(input)) throw new Error('windows_path_rejected');
  // Step 3: NFKC normalize (collapses U+FF0F → U+002F, etc.)
  const normalized = input.normalize('NFKC').replace(/\\/g, '/');
  // Step 4: lexical resolve against allowed root
  const lexical = path.resolve(allowedRoot, normalized);
  if (!lexical.startsWith(allowedRoot + path.sep) && lexical !== allowedRoot) {
    throw new Error('path_traversal: outside allowed root');
  }
  // Step 5: realpath — follows symlinks, gives true filesystem path
  let real: string;
  try {
    real = await fs.realpath(lexical);
  } catch (err: any) {
    if (err.code === 'ENOENT') throw new Error('file_not_found');
    throw err;
  }
  // Step 6: containment check on symlink-resolved path
  if (!real.startsWith(allowedRoot + path.sep) && real !== allowedRoot) {
    throw new Error('symlink_escape: resolved path outside allowed root');
  }
  return real;
}

The safe six-step path validation sequence

Combining all the above into a single reusable function:

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

export async function assertSafePath(
  raw: string,
  allowedRoot: string,
  options: { mustExist?: boolean } = {}
): Promise<string> {
  // 1. Reject null bytes (before any other processing)
  if (raw.includes('\x00')) throw new Error('null_byte_in_path');

  // 2. Reject URL-encoded null bytes
  if (/%00/i.test(raw)) throw new Error('encoded_null_byte_in_path');

  // 3. Reject Windows UNC and drive paths
  if (/^([a-zA-Z]:[\\\/]|[\\\/]{2})/.test(raw)) throw new Error('windows_path_rejected');

  // 4. NFKC Unicode normalization + backslash → forward slash
  const normalized = raw.normalize('NFKC').replace(/\\/g, '/');

  // 5. Lexical path resolution + containment check (catches ../ sequences)
  const lexical = path.resolve(allowedRoot, normalized);
  const sep = path.sep;
  if (!lexical.startsWith(allowedRoot + sep) && lexical !== allowedRoot) {
    throw new Error('path_traversal_blocked: outside allowed root');
  }

  // 6. Symlink resolution + final containment check (catches symlink escapes)
  let real: string;
  try {
    real = await fs.realpath(lexical);
  } catch (err: any) {
    if (err.code === 'ENOENT') {
      if (options.mustExist) throw new Error('file_not_found');
      // For new file creation, realpath the parent directory instead
      const parentReal = await fs.realpath(path.dirname(lexical));
      if (!parentReal.startsWith(allowedRoot + sep) && parentReal !== allowedRoot) {
        throw new Error('parent_dir_outside_allowed_root');
      }
      return lexical;  // Lexically safe, parent confirmed inside root
    }
    throw err;
  }
  if (!real.startsWith(allowedRoot + sep) && real !== allowedRoot) {
    throw new Error('symlink_escape_blocked');
  }
  return real;
}

Allowed root must not have a trailing slash: The containment check uses startsWith(allowedRoot + '/'). If allowedRoot already ends in /, you double-slash. Normalize the allowed root at server startup: const ALLOWED_ROOT = path.resolve(process.env.FILES_ROOT!).replace(/\/$/, '');

SkillAudit findings

Critical Path containment check without Unicode normalization — U+FF0F bypass confirmed, allows read of arbitrary files outside allowed root. Security axis −24 pts.
Critical No symlink resolution before containment check — symlink inside allowed root pointing to /etc/ bypasses containment. Security axis −22 pts.
High No null byte rejection before path processing — null byte injection bypasses extension and suffix checks. Security axis −16 pts.
Medium No Windows path format rejection — UNC and drive letter paths accepted, behavior undefined on cross-platform deployments. Security axis −10 pts.

Run a SkillAudit scan to detect path traversal vectors including Unicode normalization bypasses, null byte injection, and symlink escape in your MCP server's file tool handlers.