Skip to main content
interlace
Plugin: mcp-sdk-securityRules

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

AspectDetails
CWE ReferenceCWE-22 (Path Traversal)
SeverityHigh (CVSS 7.5)
Auto-Fix❌ No auto-fix available
CategorySecurity

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, readlink and their Sync forms take one path; copyFile, cp, rename, symlink and link take two. They are recognised through fs, fs.promises, fs/promises, require(), and the drop-ins fs-extra and graceful-fs. store.readFile(p) is not fs and is not reported.
  • Arguments are the handler's first parameter for registerTool / tool, request.params.arguments for setRequestHandler(CallToolRequestSchema), and the variables parameter of a registerResource / resource callback registered with an SDK ResourceTemplate. 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, fileURLToPath and new 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 Zod Schema.parse(args) / safeParse(args).data hands the arguments back: parsing checks their shape, not their text. A key the parsing schema restricts to z.enum / z.literal / z.nativeEnum stays the allowlist. Callers are found by resolving the callee, never by a name.
  • A containment check is an if whose 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 regex test, or a validator — and either exits (throw / return) before the fs call or wraps it. A same-file helper that performs such a check before every return (at the top of its body, or in a try there) sanitises its result; a check nested in another if does 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: in String(p).endsWith('.md') the conversion is an operand, not a check. endsWith, existsSync / stat / access, typeof and a check that does not exit are not containment.
  • A closed set is the allowlist. An argument declared with z.enum([...]), z.literal(...) or z.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 fs call outside any tool handler, which is node-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 exiting if is 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

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.