A file-reading tool that accepts arbitrary paths can read /etc/passwd, ~/.ssh/id_rsa, or any credential file on the host system — this is CWE-22 (path traversal), rated OWASP A01 (Broken Access Control). In MCP servers where the AI model constructs file paths, OWASP LLM01 (prompt injection) is the threat vector: an injected instruction tells the model to read sensitive paths and the tool executes without bounds checking. Symlink escapes are the bypass for naive prefix checks: a symlink at /workspace/escape pointing to /etc passes the startsWith('/workspace') test but reads outside the allowed scope.
High because unbounded file system access allows reading any file on the host — including SSH keys, environment files, and secrets — through path traversal or symlink escapes triggered via prompt injection.
Resolve symlinks with fs.realpathSync() before validating the path prefix. Never check the raw user-supplied string.
// src/utils/fs.ts — symlink-safe path validation
import path from 'path'
import fs from 'fs'
const ALLOWED_DIR = path.resolve('/workspace')
function safePath(input: string): string {
const resolved = path.resolve(ALLOWED_DIR, input)
// Resolve symlinks before checking prefix
const real = fs.realpathSync(resolved)
if (!real.startsWith(ALLOWED_DIR + path.sep) && real !== ALLOWED_DIR) {
throw new Error(`Access denied: path outside allowed directory`)
}
return real
}
Document the allowed directory in the tool description so clients know the scope.
ID: mcp-server.security-capabilities.fs-access-bounded
Severity: high
What to look for: Count all filesystem access operations (readFile, writeFile, readdir). Enumerate which restrict paths to allowed directories vs. which accept arbitrary paths. If the server has tools that read or write files, check that file paths are validated against an allowed directory or set of directories. Check for path traversal attacks (../../../etc/passwd). Check that the server declares its working directory or allowed paths, and enforces them. Look for path.resolve() + prefix checking, realpath validation, or chroot-style constraints. Check that symlinks are resolved before validation (to prevent symlink escapes).
Pass criteria: All file operations validate paths against an allowed directory. Path traversal (..) is blocked. Symlinks are resolved before validation. The allowed scope is documented or configurable. 100% of filesystem operations must validate paths against an allowlist of no more than 5 root directories.
Fail criteria: File operations accept arbitrary paths with no bounds checking, or path validation doesn't resolve symlinks, or .. traversal bypasses the allowed directory check.
Skip (N/A) when: The server has no file system operations. All checks skip when no MCP server is detected.
Cross-reference: For resource limits, see resource-limits. For credential protection, see no-credential-leaks.
Detail on fail: "Tool 'read_file' accepts any absolute path — can read /etc/passwd, ~/.ssh/id_rsa, or any file on the system" or "Path validation checks for '..' but doesn't resolve symlinks — a symlink inside the allowed directory can point to /etc/shadow"
Remediation: Validate and bound all file paths:
// src/utils/fs.ts — bounded filesystem access
const ALLOWED_ROOTS = ['/workspace', '/tmp']
function validatePath(p: string) { if (!ALLOWED_ROOTS.some(r => path.resolve(p).startsWith(r))) throw new Error("Access denied") }
const ALLOWED_DIR = path.resolve(process.cwd())
function validatePath(inputPath: string): string {
const resolved = path.resolve(ALLOWED_DIR, inputPath)
// Resolve symlinks to prevent symlink escapes
const real = fs.realpathSync(resolved)
if (!real.startsWith(ALLOWED_DIR)) {
throw new Error(`Path outside allowed directory: ${inputPath}`)
}
return real
}
server.tool('read_file', ..., async ({ filePath }) => {
try {
const safePath = validatePath(filePath)
const content = await fs.readFile(safePath, 'utf-8')
return { content: [{ type: 'text', text: content }] }
} catch (error) {
return { content: [{ type: 'text', text: error.message }], isError: true }
}
})