Topic: mcp server file inclusion security

MCP server file inclusion security — LFI and RFI in tool handlers

Local file inclusion (LFI) occurs when an MCP server reads a file from a path supplied by the LLM without validating that the resolved path stays within an approved base directory. A prompt-injected LLM can supply ../../../../etc/passwd, ~/.ssh/id_rsa, or /proc/self/environ as the file path argument, and the server reads and returns the contents. In a multi-agent pipeline, that exfiltrated content lands in another model's context window — or is passed directly to an attacker-controlled tool. This page covers the vulnerability patterns, how SkillAudit detects them, and the exact validation code to fix them.

The LFI attack in MCP tool handlers

The most common LFI pattern in MCP servers is a read_file or get_file_contents tool that accepts a filename or path argument from the LLM. The vulnerable pattern:

// VULNERABLE: no path validation
server.tool('read_file', 'Read a file from the project directory', {
  path: z.string(),
}, async ({ path: filePath }) => {
  // LLM can supply: ../../../../etc/passwd
  // Or: ../../../.ssh/id_rsa
  // Or: /proc/self/environ (on Linux — contains env vars including secrets)
  const content = await fs.readFile(filePath, 'utf-8')
  return { content: [{ type: 'text', text: content }] }
})

// ALSO VULNERABLE: naive prefix check on raw input
const BASE = '/app/project'
server.tool('read_file', 'Read a file from the project directory', {
  path: z.string(),
}, async ({ path: filePath }) => {
  // Bypass: '/app/project/../../../etc/passwd' starts with '/app/project'
  // before path resolution but resolves to '/etc/passwd' after
  if (!filePath.startsWith(BASE)) {
    throw new Error('Path not allowed')
  }
  const content = await fs.readFile(filePath, 'utf-8')
  return { content: [{ type: 'text', text: content }] }
})

Four LFI / file inclusion attack patterns in MCP servers

1. Directory traversal via ../ sequences

// Attack: LLM supplies a traversal path
// args.path = '../../../../etc/passwd'
// path.join(BASE, args.path) → '/etc/passwd' (traversal escape)

// Fix: resolve then check
import path from 'path'
import fs from 'fs'

const ALLOWED_BASE = path.resolve('/app/project')

function safePath(inputPath: string): string {
  // Resolve relative to the allowed base to handle relative paths
  const candidate = path.resolve(ALLOWED_BASE, inputPath)

  // realpathSync follows symlinks and resolves all '..' components
  // Throws ENOENT if path doesn't exist — catch this if needed
  let resolved: string
  try {
    resolved = fs.realpathSync(candidate)
  } catch {
    // Path doesn't exist yet (e.g., write target) — use normalize + prefix check
    resolved = path.normalize(candidate)
  }

  // Strict prefix check: includes trailing separator to prevent bypass
  if (!resolved.startsWith(ALLOWED_BASE + path.sep) && resolved !== ALLOWED_BASE) {
    throw new Error(`Path traversal attempt: ${inputPath} resolves outside allowed base`)
  }

  return resolved
}

server.tool('read_file', 'Read a file from the project directory', {
  path: z.string().max(500),
}, async ({ path: filePath }) => {
  const safeFp = safePath(filePath)
  const content = await fs.promises.readFile(safeFp, 'utf-8')
  return { content: [{ type: 'text', text: content }] }
})

2. Symlink escape

// Attack: a symlink inside the allowed directory points outside it
// /app/project/config -> /etc/aws/credentials  (symlink placed by attacker)
// A naive prefix check on the resolved path of the symlink itself passes
// because /app/project/config starts with /app/project

// Fix: realpathSync follows ALL symlinks before the prefix check
// If /app/project/config is a symlink to /etc/aws/credentials,
// realpathSync('/app/project/config') returns '/etc/aws/credentials'
// which then fails the prefix check for '/app/project'

// The safePath() function above handles this correctly because
// realpathSync() is called before the prefix check.

// Additional defense: open files with O_NOFOLLOW to prevent symlink following at kernel level
// (Linux only, requires low-level fs bindings — use realpathSync for portability)

3. Zip slip — path traversal during archive extraction

// Attack: a zip/tar archive entry has a path like '../../etc/cron.d/backdoor'
// During extraction, this writes to /etc/cron.d/backdoor

// Vulnerable extraction:
import AdmZip from 'adm-zip'

server.tool('extract_archive', 'Extract an uploaded ZIP archive', {
  archivePath: z.string(),
  extractTo: z.string(),
}, async ({ archivePath, extractTo }) => {
  const zip = new AdmZip(safePath(archivePath))
  // VULNERABLE: AdmZip.extractAllTo does not check for path traversal
  zip.extractAllTo(extractTo, true)
  return { content: [{ type: 'text', text: 'Extracted' }] }
})

// Fix: validate each entry's destination before writing
import AdmZip from 'adm-zip'
import path from 'path'
import fs from 'fs'

server.tool('extract_archive', 'Extract an uploaded ZIP archive', {
  archivePath: z.string(),
}, async ({ archivePath }) => {
  const safeArchive = safePath(archivePath)
  const extractBase = path.join(ALLOWED_BASE, 'extracted')
  const zip = new AdmZip(safeArchive)

  for (const entry of zip.getEntries()) {
    // Resolve each entry's destination path before writing
    const entryDest = path.resolve(extractBase, entry.entryName)
    const normalized = path.normalize(entryDest)
    if (!normalized.startsWith(extractBase + path.sep)) {
      throw new Error(`Zip slip detected: ${entry.entryName} extracts outside base`)
    }
    // Safe to extract this entry
    fs.mkdirSync(path.dirname(normalized), { recursive: true })
    if (!entry.isDirectory) {
      fs.writeFileSync(normalized, entry.getData())
    }
  }
  return { content: [{ type: 'text', text: 'Extracted safely' }] }
})

4. Remote file inclusion via LLM-controlled URL

// Attack: tool fetches a URL supplied by the LLM
// args.url = 'file:///etc/passwd'           (file: scheme local read)
// args.url = 'http://169.254.169.254/...'   (SSRF to cloud metadata)
// args.url = 'dict://internal-redis:6379'  (SSRF via non-HTTP scheme)

// VULNERABLE:
server.tool('fetch_resource', 'Fetch content from a URL', {
  url: z.string().url(),
}, async ({ url }) => {
  const response = await fetch(url)  // No scheme or host validation
  const text = await response.text()
  return { content: [{ type: 'text', text: text }] }
})

// Fix: allowlist permitted hosts and enforce HTTPS-only
const ALLOWED_HOSTS = new Set(['api.github.com', 'registry.npmjs.org', 'raw.githubusercontent.com'])

server.tool('fetch_resource', 'Fetch content from an approved source', {
  url: z.string().url(),
}, async ({ url }) => {
  const parsed = new URL(url)

  // Enforce HTTPS scheme — blocks file:, dict:, gopher:, ftp: etc.
  if (parsed.protocol !== 'https:') {
    throw new Error('Only HTTPS URLs are permitted')
  }

  // Allowlist check on hostname
  if (!ALLOWED_HOSTS.has(parsed.hostname)) {
    throw new Error(`Host not in allowlist: ${parsed.hostname}`)
  }

  // Block private/internal IP ranges even if hostname resolves to one
  // (implement your preferred SSRF IP block check here)

  const response = await fetch(url)
  const text = await response.text()
  return { content: [{ type: 'text', text: text.slice(0, 100_000) }] }
})

What SkillAudit checks

See also

Check your MCP server for file inclusion vulnerabilities before directory submission.

Run a free audit → How grading works →