penpot/mcp/packages/server/src/ReplServer.ts
Andrey Antukh 798ee46b4a 🐛 Bind MCP ReplServer to localhost to prevent unauthenticated RCE
The ReplServer Express app was calling `app.listen(port)` with no host
argument, causing Node/Express to default to binding on all interfaces
(0.0.0.0). Combined with the unauthenticated /execute endpoint, any
network peer could POST arbitrary JS and get it run inside the MCP
process.

Fix: add a `host` parameter (default "localhost") to the ReplServer
constructor and pass it to `app.listen`. The call site in
PenpotMcpServer now forwards `this.host` (sourced from
PENPOT_MCP_SERVER_HOST env var, default "localhost"), so environment-
variable overrides continue to work.

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-05-07 12:59:31 +02:00

114 lines
3.7 KiB
TypeScript

import express from "express";
import path from "path";
import { fileURLToPath } from "url";
import { PluginBridge } from "./PluginBridge";
import { ExecuteCodePluginTask } from "./tasks/ExecuteCodePluginTask";
import { createLogger } from "./logger";
/**
* Web-based REPL server for executing code through the PluginBridge.
*
* Provides a REPL-style HTML interface that allows users to input
* JavaScript code and execute it via ExecuteCodePluginTask instances.
* The interface maintains command history, displays logs in &lt;pre&gt; tags,
* and shows results in visually separated blocks.
*/
export class ReplServer {
private readonly logger = createLogger("ReplServer");
private readonly app: express.Application;
private readonly port: number;
private readonly host: string;
private server: any;
constructor(
private readonly pluginBridge: PluginBridge,
port: number = 4403,
host: string = "localhost"
) {
this.port = port;
this.host = host;
this.app = express();
this.setupMiddleware();
this.setupRoutes();
}
/**
* Sets up Express middleware for request parsing and static content.
*/
private setupMiddleware(): void {
this.app.use(express.json());
}
/**
* Sets up HTTP routes for the REPL interface and API endpoints.
*/
private setupRoutes(): void {
// serve the main REPL interface
this.app.get("/", (req, res) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const htmlPath = path.join(__dirname, "static", "repl.html");
res.sendFile(htmlPath);
});
// API endpoint for executing code
this.app.post("/execute", async (req, res) => {
try {
const { code } = req.body;
if (!code || typeof code !== "string") {
return res.status(400).json({
error: "Code parameter is required and must be a string",
});
}
const task = new ExecuteCodePluginTask({ code });
const result = await this.pluginBridge.executePluginTask(task);
// extract the result member from ExecuteCodeTaskResultData
const executeResult = result.data?.result;
res.json({
success: true,
result: executeResult,
log: result.data?.log || "",
});
} catch (error) {
this.logger.error(error, "Failed to execute code in REPL");
res.status(500).json({
error: error instanceof Error ? error.message : "Unknown error occurred",
});
}
});
}
/**
* Starts the REPL web server.
*
* Begins listening on the configured port and logs server startup information.
*/
public async start(): Promise<void> {
return new Promise((resolve) => {
this.server = this.app.listen(this.port, this.host, () => {
this.logger.info(`REPL server started on port ${this.port}`);
this.logger.info(`REPL interface URL: http://${this.host}:${this.port}`);
resolve();
});
});
}
/**
* Stops the REPL web server.
*/
public async stop(): Promise<void> {
if (this.server) {
return new Promise((resolve) => {
this.server.close(() => {
this.logger.info("REPL server stopped");
resolve();
});
});
}
}
}