{"schema":"threatline.record/2","record":{"id":"GHSA-ch2q-6x56-qg5r","aliases":["GHSA-ch2q-6x56-qg5r"],"summary":"Sibling of GHSA-22qr-rp27-j9wm: MCP plugin WebSocket bridge binds to all interfaces regardless of PENPOT_MCP_SERVER_HOST, no dev-flag required","details":"## Summary\r\n\r\nThe 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.\r\n\r\n`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:\r\n\r\n```typescript\r\nthis.wsServer = new WebSocketServer({ port: port });\r\n```\r\n\r\nThe `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.\r\n\r\n## Details\r\n\r\nIn single-user mode (the default, no `--multi-user` flag), `PluginBridge`'s connection handler requires no `userToken` at all:\r\n\r\n```typescript\r\nif (this.mcpServer.isMultiUserMode() && !userToken) {\r\n    ws.close(1008, \"Missing userToken parameter\");\r\n    return;\r\n}\r\n```\r\n\r\nand `getClientConnection()` simply uses whichever single WebSocket client is connected:\r\n\r\n```typescript\r\nif (this.connectedClients.size === 0) { throw new Error(...); }\r\nif (this.connectedClients.size > 1) { throw new Error(...); }\r\nconst connection = this.connectedClients.values().next().value;\r\nreturn <ClientConnection>connection;\r\n```\r\n\r\nBecause 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:\r\n\r\n```typescript\r\npublic async executePluginTask<TResult>(task): Promise<TResult> {\r\n    this.sendPluginTask(task, this.redisBridge !== undefined);\r\n    return await task.getResultPromise();\r\n}\r\n```\r\n\r\nAn 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.\r\n\r\n## PoC\r\n\r\nConfirmed 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.\r\n\r\n## Impact\r\n\r\nAny 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.\r\n\r\n## Suggested fix\r\n\r\nPass the same host already used by the rest of the MCP server (`mcpServer.host`) into the WebSocket server's options:\r\n\r\n```typescript\r\nthis.wsServer = new WebSocketServer({ port: port, host: mcpServer.host });\r\n```\r\n\r\nThis mirrors the exact fix GHSA-22qr-rp27-j9wm applied to `ReplServer.ts`, extended to the one sibling component it did not cover.\r\n\r\nCross-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).\r\n","categories":["Unclassified"],"product":"penpot/penpot","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/penpot/penpot/security/advisories/GHSA-ch2q-6x56-qg5r","source_modified_at":"2026-09-25T18:55:42Z","fetched_at":"2026-10-24T05:41:33.294140Z","package":{"ecosystem":"other","name":"penpot/penpot"},"range":"<= 2.17.2"}],"fixed_versions":[{"source":"GitHub Advisory Database","source_url":"https://github.com/penpot/penpot/security/advisories/GHSA-ch2q-6x56-qg5r","source_modified_at":"2026-09-25T18:55:42Z","fetched_at":"2026-10-24T05:41:33.294140Z","package":{"ecosystem":"other","name":"penpot/penpot"},"version":"2.18.0"}],"cvss":null,"severity":"Low","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-09-25T18:55:42Z","modified_at":"2026-09-25T18:55:42Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/penpot/penpot/security/advisories/GHSA-ch2q-6x56-qg5r","fetched_at":"2026-10-24T05:41:33.294140Z","source_modified_at":"2026-09-25T18:55:42Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-09-25T18:55:42Z","content_hash":"5f0ef095718dd3bedf3d074291e37661ebab5effd99a0d9e060d65de8a5837a3"}}
