All vulnerabilities
GHSA-ch2q-6x56-qg5r

penpot/penpot: Sibling of GHSA-22qr-rp27-j9wm: MCP plugin WebSocket bridge binds to all interfaces regardless of PENPOT_MCP_SERVER_HOST, no dev-flag required

penpot/penpot
CVSS
n/aLow
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in2.18.0Fixed versionGitHub
Published25 Sep 2026Updated 25 Sep 2026GitHub

What fixes it

  • 2.18.0penpot/penpot, otherGitHub

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

Summary

The MCP module's plugin WebSocket bridge (PluginBridge.ts) always binds its WebSocket server to all interfaces, regardless of PENPOT_MCP_SERVER_HOST, in both single-user and multi-user mode, with no dev/remote-mode flag required.

mcp/README.md documents PENPOT_MCP_SERVER_HOST as controlling the bind address for "the MCP server" (default localhost), and its "Beyond Local Execution" section frames binding to 0.0.0.0 as something an operator explicitly opts into for remote connectivity ("use caution in untrusted networks"). This matches the code for the main HTTP server (PenpotMcpServer.ts, correctly binds this.host) and, after GHSA-22qr-rp27-j9wm's fix, the REPL server (ReplServer.ts, also correctly binds this.host). PluginBridge.ts's WebSocket server was never included in that fix and never wired to any host variable at all:

this.wsServer = new WebSocketServer({ port: port });

The ws library defaults to all interfaces when no host option is given. This is a distinct component from the one GHSA-22qr-rp27-j9wm addressed (the REPL's Express server), and unlike the REPL, this WebSocket server is not behind any PENPOT_MCP_DEVENV/PENPOT_MCP_REPL_ENABLE gate: it is constructed unconditionally whenever a PenpotMcpServer is constructed, meaning it runs in every standard local, single-user, non-Docker setup, the primary documented "Getting Started" usage of the MCP module.

Details

In single-user mode (the default, no --multi-user flag), PluginBridge's connection handler requires no userToken at all:

if (this.mcpServer.isMultiUserMode() && !userToken) {
    ws.close(1008, "Missing userToken parameter");
    return;
}

and getClientConnection() simply uses whichever single WebSocket client is connected:

if (this.connectedClients.size === 0) { throw new Error(...); }
if (this.connectedClients.size > 1) { throw new Error(...); }
const connection = this.connectedClients.values().next().value;
return <ClientConnection>connection;

Because the WebSocket server binds all interfaces, any network-reachable host can connect to this port directly, with no authentication, and be treated as "the" connected plugin instance. Any MCP tool call that dispatches a task (execute_code, export_shape, import_penpot_file, and in devenv cljs_repl) sends task.toRequest() straight to whichever connection is registered:

public async executePluginTask<TResult>(task): Promise<TResult> {
    this.sendPluginTask(task, this.redisBridge !== undefined);
    return await task.getResultPromise();
}

An attacker who connects before (or instead of) the legitimate Penpot browser tab receives the task payload intended for that tab (e.g. the JavaScript source for execute_code, or file content for import_penpot_file) and controls the fabricated result returned to the MCP client, hijacking the plugin communication channel with no credentials of any kind. If the legitimate plugin is already connected, a second unauthenticated connection instead causes getClientConnection() to refuse all task dispatch ("Multiple ... instances are connected"), an unauthenticated denial of service against the whole MCP integration.

PoC

Confirmed by constructing the unmodified PluginBridge class from this repository's own source with a PenpotMcpServer-equivalent host of localhost (the documented default) and inspecting the resulting WebSocketServer's bound address: it reports :: (all interfaces), not 127.0.0.1/localhost, confirming the server is reachable from any host on the network regardless of the documented PENPOT_MCP_SERVER_HOST default.

Impact

Any standard local, non-Docker, single-user run of the MCP server (the module's primary documented usage) exposes an unauthenticated channel that lets any network-reachable attacker hijack or deny plugin task execution, independent of any dev/remote-mode flag and independent of PENPOT_MCP_SERVER_HOST, which the documentation states should control exactly this.

Suggested fix

Pass the same host already used by the rest of the MCP server (mcpServer.host) into the WebSocket server's options:

this.wsServer = new WebSocketServer({ port: port, host: mcpServer.host });

This mirrors the exact fix GHSA-22qr-rp27-j9wm applied to ReplServer.ts, extended to the one sibling component it did not cover.

Cross-references GHSA-22qr-rp27-j9wm (same missing-host-argument root cause, different component) and GHSA-852x-m8p9-558v (this session's prior finding extending the same advisory in a different direction).

Text from GitHub Advisory Database

Affected versions 1

  • penpot/penpot, other<= 2.17.2GitHub