no-path-traversal-in-tool
Disallow an MCP tool argument choosing the file an fs call reads, writes or deletes without a containment check.
CWE: CWE-22 OWASP: A01:2021 – Broken Access Control
Detects a tool argument or a ResourceTemplate variable reaching the path of an fs call that reads, writes, lists or removes, with no containment check on the way. This rule is part of eslint-plugin-mcp-sdk-security.
💼 This rule is set to error in the recommended and strict configs.
Quick Summary
| Aspect | Details |
|---|---|
| CWE Reference | CWE-22 (Path Traversal) |
| Severity | High (CVSS 7.5) |
| Auto-Fix | ❌ No auto-fix available |
| Category | Security |
Why this matters
Filesystem servers are the most installed kind of MCP server, and the most
common defect in them is the one behind EscapeRoute (CVE-2025-53109 /
CVE-2025-53110) and the mcp-server-git path CVEs: the tool takes a path,
joins it onto a root, and hands it to fs. ../../.ssh/id_rsa then reads the
key. A symlink, or a sibling whose name starts with the root's name
(/data-evil next to /data), walks out of a check that only compares
prefixes.
The path comes from the model's tool call, and the model can be steered by any
file, page or tool output it has read. A ResourceTemplate variable is no
better: it is parsed out of a URI the client chose.
Relationship to node-security/detect-non-literal-fs-filename
detect-non-literal-fs-filename reports any non-literal path anywhere. Inside
an MCP tool handler the source is known — the handler's own parameter — so this
rule follows it and also recognises the containment idiom, which a
source-agnostic rule cannot. Both may report the same line. Tool handlers are
this plugin's scope (owner decision, 2026-10).
❌ Incorrect
// ❌ joined onto a root — `../` still walks out
server.registerTool('read', cfg, async ({ path: p }) =>
readFile(path.join(ROOT, p), 'utf8'),
);
// ❌ a resource template variable is client-chosen too
server.registerResource(
'doc',
new ResourceTemplate('docs://{name}', { list: undefined }),
{},
async (uri, { name }) => ({
contents: [
{ uri: uri.href, text: await readFile(path.join(ROOT, name), 'utf8') },
],
}),
);
// ❌ an extension or existence check is not containment
server.registerTool('read', cfg, async ({ p }) => {
if (!p.endsWith('.md') || !existsSync(path.join(ROOT, p)))
throw new Error('no');
return readFile(path.join(ROOT, p), 'utf8');
});How sinks, arguments and checks are recognised
- Sinks are resolved to
fs, never matched by name:readFile,writeFile,appendFile,createReadStream,createWriteStream,open,opendir,readdir,mkdir,rm,rmdir,unlink,truncate,chmod,chown,readlinkand theirSyncforms take one path;copyFile,cp,rename,symlinkandlinktake two. They are recognised throughfs,fs.promises,fs/promises,require(), and the drop-insfs-extraandgraceful-fs.store.readFile(p)is notfsand is not reported. - Arguments are the handler's first parameter for
registerTool/tool,request.params.argumentsforsetRequestHandler(CallToolRequestSchema), and thevariablesparameter of aregisterResource/resourcecallback registered with an SDKResourceTemplate. A static resource URI is fixed, so its callback is not a source. - The value is followed within the file: through bindings, destructuring,
templates,
path.join/resolve/normalize/dirname,fs.realpath,fileURLToPathandnew URL(), all of which still point wherever their input did.path.basename(p)strips every directory and is not followed. - Into helpers, elements and parsed arguments (1.0). A same-file
function or class method (
this.run(args.cmd)) the handler calls carries what its callers pass it, so a sink inside the helper is reported — an object literal it is given is read field by field. Each element of an argument array is the argument (for (const p of paths),paths.map((p) => …)), and a ZodSchema.parse(args)/safeParse(args).datahands the arguments back: parsing checks their shape, not their text. A key the parsing schema restricts toz.enum/z.literal/z.nativeEnumstays the allowlist. Callers are found by resolving the callee, never by a name. - A containment check is an
ifwhose test checks the value — or a name derived from it — with a call:target.startsWith(root + sep),path.relative(root, target)/path.isAbsolute(...),ALLOWED.includes(p), a regextest, or a validator — and either exits (throw/return) before thefscall or wraps it. A same-file helper that performs such a check before every return (at the top of its body, or in atrythere) sanitises its result; a check nested in anotherifdoes not. A caller that confines the path before passing it to a helper hands the helper a checked value. A same-file predicate in the test (if (!isInside(p))) counts only when its own body makes a check-shaped call; one that only returns or probes the path (pick(p),existsSync(a)) does not. A call that is not a check is not searched for one inside it: inString(p).endsWith('.md')the conversion is an operand, not a check.endsWith,existsSync/stat/access,typeofand a check that does not exit are not containment. - A closed set is the allowlist. An argument declared with
z.enum([...]),z.literal(...)orz.nativeEnum(...)is not reported.
✅ Correct
server.registerTool('read', cfg, async ({ path: requested }) => {
const root = await realpath(ROOT);
const target = await realpath(path.resolve(root, requested));
const rel = path.relative(root, target);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
return {
isError: true,
content: [{ type: 'text', text: 'outside the workspace' }],
};
}
return { content: [{ type: 'text', text: await readFile(target, 'utf8') }] };
});realpath resolves symlinks first, so the check sees where the path really
points; path.relative has no prefix-collision problem.
What this rule deliberately does not report
- A path that is not a tool argument — a config value, a constant.
- An
fscall outside any tool handler, which isnode-security/detect-non-literal-fs-filename's question. - A path checked by a validator this rule cannot see into. An imported
isInside(root, p)in an exitingifis trusted to do what its call site says. - A file that imports no MCP server package.
When Not To Use It
There is no configuration in which a model-chosen path should reach the filesystem unconfined, so this rule has no options.
Further Reading
Use this rule
npm install -D oxlint-plugin-mcp-sdk-security{ "jsPlugins": ["oxlint-plugin-mcp-sdk-security"], "rules": { "mcp-sdk-security/no-path-traversal-in-tool": "error" }}import { defineConfig } from 'vite-plus';export default defineConfig({ lint: { jsPlugins: ['oxlint-plugin-mcp-sdk-security'], rules: { 'mcp-sdk-security/no-path-traversal-in-tool': 'error', }, },});oxlint-plugin-mcp-sdk-security re-exports eslint-plugin-mcp-sdk-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.
Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.