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
- File path argument used in
fs.readFile,fs.writeFile,readFileSync,createReadStreamwithoutrealpathSyncvalidation — HIGH; direct LFI path from LLM-controlled argument to file read - Naive string prefix check on raw path input (not resolved) — HIGH; bypassed by
../sequences that pass the raw prefix check but escape after normalization - Archive extraction without per-entry destination validation — HIGH; zip slip writes attacker-controlled content to arbitrary filesystem paths
- URL fetch with LLM-controlled URL and no scheme or host validation — HIGH; RFI / SSRF vector;
file:scheme enables local file reads - File path argument with no
.max()length constraint — WARN; long paths with many../sequences are harder to detect and may bypass some mitigations
See also
- MCP server path traversal — directory traversal and symlink escape patterns
- MCP server SSRF — server-side request forgery via LLM-controlled URL arguments
- MCP server input validation — Zod schema validation including path argument patterns
- MCP server security checklist — comprehensive pre-submission checklist
- How to write a zero-finding MCP server — construction guide covering filesystem access patterns
Check your MCP server for file inclusion vulnerabilities before directory submission.
Run a free audit → How grading works →