Skip to content

filesystem: a Windows-style path is silently accepted on POSIX and becomes a literal filename inside the allowed root #4686

Description

@gcp007-ops

Describe the bug

On a POSIX host, passing a Windows-style path such as C:\Users\me\notes\file.md to write_file does not fail. It succeeds, and the server answers Successfully wrote to C:\Users\me\notes\file.md.

What actually happens is that a single file is created directly inside the allowed root, whose filename is the entire string, backslashes included:

/Users/me/C:\Users\me\notes\file.md

This is not a containment failure — nothing escapes the sandbox, and isPathWithinAllowedDirectories is doing its job. It is the absence of a rejection for a string that can only mean something on another OS. The caller believes it wrote to a Windows location; what exists is an unpronounceable file at the root of the allowed directory, and the success message gives no hint. In our case the file sat unnoticed for about three weeks.

Why it happens

In src/filesystem/lib.ts:

export async function validatePath(requestedPath: string): Promise<string> {
  const expandedPath = expandHome(requestedPath);
  const absolute = path.isAbsolute(expandedPath)
    ? path.resolve(expandedPath)
    : resolveRelativePathAgainstAllowedDirectories(expandedPath);

Under POSIX, path.isAbsolute("C:\\Users\\me\\notes\\file.md") is false. The string therefore takes the relative branch, and resolveRelativePathAgainstAllowedDirectories does path.resolve(allowedDir, thatString). The result lands inside the allowed root, so isPathWithinAllowedDirectories legitimately approves it, and the write proceeds.

The same reasoning applies to C:/Users/me/... — there the directory tree C:/Users/... is created inside the allowed root instead.

To Reproduce

  1. Run the server on macOS or Linux with a single allowed directory, e.g. /tmp/sandbox.
  2. Call write_file with path: "C:\\Users\\me\\notes\\file.md" and any content.
  3. Observe the response: Successfully wrote to C:\Users\me\notes\file.md.
  4. ls /tmp/sandbox shows one entry named C:\Users\me\notes\file.md.

Expected behavior

validatePath should refuse a string that matches a Windows drive-letter prefix (^[A-Za-z]:[\\/], and the bare ^[A-Za-z]:$) when process.platform !== 'win32', instead of silently treating it as a relative path. An explicit error — something like Access denied - Windows-style path received on a POSIX host — turns a silent, late-discovered mistake into an immediate one.

A narrower alternative, if outright rejection is considered too strict: keep accepting it but never let a drive-letter string reach the relative branch, and say so in the response.

Please be careful not to reintroduce #3628

That report is the mirror image of this one: a Linux path like /h/user/data was converted into H:\user\data. The fix proposed here is deliberately one-directional and platform-guarded — reject the drive-letter form, only when not running on Windows, and never rewrite POSIX paths.

Environment

  • @modelcontextprotocol/server-filesystem — reproduced against the code currently on main (src/filesystem/lib.ts, the validatePath / resolveRelativePathAgainstAllowedDirectories pair quoted above).
  • Also reproduced against the shipped Claude Desktop Filesystem extension, version 2026.7.10, on macOS with Node v24.19.0.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions